refactor(doc): move appearance.rs tests to appearance/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:18 +00:00
parent 17e36b62b1
commit 56b9bf5fd3
2 changed files with 314 additions and 315 deletions
+1 -315
View File
@@ -1151,318 +1151,4 @@ impl Appearance {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_attributes_show_again() {
let mut a = Appearance::default_art();
assert!(!a.has_hidden() && !a.show_all());
a.stroke_mut().unwrap().visible = false;
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
assert!(a.has_hidden());
assert!(a.show_all());
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
}
#[test]
fn basic_appearance() {
let a = Appearance::default_art();
assert!(a.is_basic());
assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE));
assert_eq!(a.stroke_width(), 1.0);
}
#[test]
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
use vectorcraft_geom::{Point, Rect};
let placed = |x: f64| {
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None });
Paint::Gradient(Box::new(g))
};
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
let start = |p: Paint| match p {
Paint::Gradient(g) => g.geom.unwrap().start,
_ => panic!("a gradient"),
};
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
}
#[test]
fn items_by_index() {
let mut a = Appearance::default_art();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
// [Fill white, Stroke black, Fill none]
assert_eq!(a.fill_at(None).unwrap().paint, Paint::None);
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE));
assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none());
assert_eq!(a.item_of_kind(Some(1), false), Some(1));
assert_eq!(a.item_of_kind(Some(1), true), None);
assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None);
assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK)));
assert!(!a.set_stroke_at(Some(0), Paint::None));
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK));
a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1);
assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none());
assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill"));
}
#[test]
fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() };
assert!(!a.is_basic(), "two strokes");
a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic());
let mut b = Appearance::default_art();
b.stroke_mut().unwrap().visible = false;
assert!(!b.is_basic(), "a hidden stroke");
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.5;
assert!(!c.is_basic(), "fill opacity");
let mut d = Appearance::default_art();
d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
assert!(!d.is_basic(), "two fills");
let mut e = Appearance::default_art();
e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert!(!e.is_basic(), "an effect");
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
a.set_fill(Paint::solid(Color::BLACK));
a.set_stroke(Paint::solid(Color::WHITE));
assert_eq!(a.items.len(), 2);
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
assert_eq!(p.at(0.5), (1.0, 1.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
assert_eq!(p.at(2.0), (0.0, 0.0));
}
#[test]
fn profile_presets_round_trip_their_ids() {
for p in WidthProfile::PRESETS {
let prof = WidthProfile::preset(p.id).unwrap();
assert_eq!(prof.preset_id(), Some(p.id));
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
}
assert_eq!(WidthProfile::id_of(None), "uniform");
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
assert!(WidthProfile::preset("nope").is_none());
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
}
#[test]
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
// Saved files store these names: they may only ever be appended to.
const SAVED: [&str; 40] = [
"Triangle",
"TriangleOpen",
"Circle",
"CircleOpen",
"Square",
"SquareOpen",
"Bar",
"Diamond",
"Arrow",
"ArrowOpen",
"Barbed",
"HalfArrowLeft",
"HalfArrowRight",
"Concave",
"DoubleBar",
"Feather",
"DotOnBar",
"Chevron",
"DoubleArrow",
"Target",
"Star",
"Cross",
"Plus",
"Hexagon",
"HexagonOpen",
"Tag",
"TagOpen",
"HalfCircle",
"Drop",
"Slash",
"DoubleSlash",
"DiamondOpen",
"TriangleReverse",
"Swallowtail",
"Bracket",
"Fork",
"Leaf",
"Kite",
"TriangleBar",
"Oval",
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
assert_eq!(serde_json::to_value(a).unwrap(), name);
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
}
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
assert_eq!(st.arrow_align, ArrowAlign::Extend);
// The default is not written, so older readers see the same JSON as before.
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
assert_eq!(old.arrow_align, ArrowAlign::Extend);
st.arrow_align = ArrowAlign::Tip;
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
assert_eq!(back, st);
}
#[test]
fn outset_covers_arrowheads() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 2.0);
let st = a.stroke_mut().unwrap();
st.end_arrow = Some(Arrowhead::Triangle);
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
a.stroke_mut().unwrap().width = 0.01;
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
}
#[test]
fn outset_depends_on_align() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 5.0);
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
assert_eq!(a.outset(), 10.0);
// Open paths stroke inside-aligned strokes centred.
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 5.0);
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
}
#[test]
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
// Miter joins can spike out to the limit × half the weight.
assert_eq!(a.outset(), 10.0);
let st = a.stroke_mut().unwrap();
st.join = LineJoin::Bevel;
st.cap = LineCap::Square;
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
let st = a.stroke_mut().unwrap();
st.cap = LineCap::Round;
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
assert_eq!(st.profile_max(), 2.5);
assert_eq!(a.outset(), 2.5);
a.stroke_mut().unwrap().paint = Paint::None;
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
}
#[test]
fn contents_slot_follows_item_edits_and_round_trips() {
let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None));
let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() };
assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default");
a.set_contents_at(1);
assert!(!a.is_basic());
// [0 | 1 2]: removing below shifts the slot, removing above doesn't.
a.remove_item(2);
assert_eq!(a.contents_at(), 1);
a.remove_item(0);
assert_eq!((a.contents_at(), a.contents_index), (0, None));
// Inserting next to the slot joins the item below it.
a.items.push(fill());
a.set_contents_at(1); // [0 | 1]
a.insert_item(1, fill());
assert_eq!(a.contents_at(), 2);
a.insert_item(3, fill()); // [0 1 | 2 3]
assert_eq!(a.contents_at(), 2);
// Moves keep their side when landing next to the slot, cross it otherwise.
assert_eq!(a.move_item(3, 2), Some(2));
assert_eq!(a.contents_at(), 2, "stays above");
a.move_item(3, 0);
assert_eq!(a.contents_at(), 3, "crossed below");
a.move_item(0, 3);
assert_eq!(a.contents_at(), 2, "crossed above");
assert_eq!(a.move_item(9, 0), None);
// Saved only when set; older files load with every item above.
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contents_index\":2"));
assert_eq!(serde_json::from_str::<Appearance>(&json).unwrap(), a);
let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap();
assert_eq!(old.contents_index, None);
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
}
}
mod tests;
+313
View File
@@ -0,0 +1,313 @@
use super::*;
#[test]
fn hidden_attributes_show_again() {
let mut a = Appearance::default_art();
assert!(!a.has_hidden() && !a.show_all());
a.stroke_mut().unwrap().visible = false;
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
assert!(a.has_hidden());
assert!(a.show_all());
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
}
#[test]
fn basic_appearance() {
let a = Appearance::default_art();
assert!(a.is_basic());
assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE));
assert_eq!(a.stroke_width(), 1.0);
}
#[test]
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
use vectorcraft_geom::{Point, Rect};
let placed = |x: f64| {
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None });
Paint::Gradient(Box::new(g))
};
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
let start = |p: Paint| match p {
Paint::Gradient(g) => g.geom.unwrap().start,
_ => panic!("a gradient"),
};
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
}
#[test]
fn items_by_index() {
let mut a = Appearance::default_art();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
// [Fill white, Stroke black, Fill none]
assert_eq!(a.fill_at(None).unwrap().paint, Paint::None);
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE));
assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none());
assert_eq!(a.item_of_kind(Some(1), false), Some(1));
assert_eq!(a.item_of_kind(Some(1), true), None);
assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None);
assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK)));
assert!(!a.set_stroke_at(Some(0), Paint::None));
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK));
a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1);
assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none());
assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill"));
}
#[test]
fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() };
assert!(!a.is_basic(), "two strokes");
a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic());
let mut b = Appearance::default_art();
b.stroke_mut().unwrap().visible = false;
assert!(!b.is_basic(), "a hidden stroke");
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.5;
assert!(!c.is_basic(), "fill opacity");
let mut d = Appearance::default_art();
d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
assert!(!d.is_basic(), "two fills");
let mut e = Appearance::default_art();
e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert!(!e.is_basic(), "an effect");
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
a.set_fill(Paint::solid(Color::BLACK));
a.set_stroke(Paint::solid(Color::WHITE));
assert_eq!(a.items.len(), 2);
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
assert_eq!(p.at(0.5), (1.0, 1.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
assert_eq!(p.at(2.0), (0.0, 0.0));
}
#[test]
fn profile_presets_round_trip_their_ids() {
for p in WidthProfile::PRESETS {
let prof = WidthProfile::preset(p.id).unwrap();
assert_eq!(prof.preset_id(), Some(p.id));
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
}
assert_eq!(WidthProfile::id_of(None), "uniform");
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
assert!(WidthProfile::preset("nope").is_none());
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
}
#[test]
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
// Saved files store these names: they may only ever be appended to.
const SAVED: [&str; 40] = [
"Triangle",
"TriangleOpen",
"Circle",
"CircleOpen",
"Square",
"SquareOpen",
"Bar",
"Diamond",
"Arrow",
"ArrowOpen",
"Barbed",
"HalfArrowLeft",
"HalfArrowRight",
"Concave",
"DoubleBar",
"Feather",
"DotOnBar",
"Chevron",
"DoubleArrow",
"Target",
"Star",
"Cross",
"Plus",
"Hexagon",
"HexagonOpen",
"Tag",
"TagOpen",
"HalfCircle",
"Drop",
"Slash",
"DoubleSlash",
"DiamondOpen",
"TriangleReverse",
"Swallowtail",
"Bracket",
"Fork",
"Leaf",
"Kite",
"TriangleBar",
"Oval",
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
assert_eq!(serde_json::to_value(a).unwrap(), name);
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
}
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
assert_eq!(st.arrow_align, ArrowAlign::Extend);
// The default is not written, so older readers see the same JSON as before.
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
assert_eq!(old.arrow_align, ArrowAlign::Extend);
st.arrow_align = ArrowAlign::Tip;
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
assert_eq!(back, st);
}
#[test]
fn outset_covers_arrowheads() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 2.0);
let st = a.stroke_mut().unwrap();
st.end_arrow = Some(Arrowhead::Triangle);
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
a.stroke_mut().unwrap().width = 0.01;
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
}
#[test]
fn outset_depends_on_align() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 5.0);
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
assert_eq!(a.outset(), 10.0);
// Open paths stroke inside-aligned strokes centred.
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 5.0);
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
}
#[test]
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
// Miter joins can spike out to the limit × half the weight.
assert_eq!(a.outset(), 10.0);
let st = a.stroke_mut().unwrap();
st.join = LineJoin::Bevel;
st.cap = LineCap::Square;
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
let st = a.stroke_mut().unwrap();
st.cap = LineCap::Round;
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
assert_eq!(st.profile_max(), 2.5);
assert_eq!(a.outset(), 2.5);
a.stroke_mut().unwrap().paint = Paint::None;
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
}
#[test]
fn contents_slot_follows_item_edits_and_round_trips() {
let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None));
let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() };
assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default");
a.set_contents_at(1);
assert!(!a.is_basic());
// [0 | 1 2]: removing below shifts the slot, removing above doesn't.
a.remove_item(2);
assert_eq!(a.contents_at(), 1);
a.remove_item(0);
assert_eq!((a.contents_at(), a.contents_index), (0, None));
// Inserting next to the slot joins the item below it.
a.items.push(fill());
a.set_contents_at(1); // [0 | 1]
a.insert_item(1, fill());
assert_eq!(a.contents_at(), 2);
a.insert_item(3, fill()); // [0 1 | 2 3]
assert_eq!(a.contents_at(), 2);
// Moves keep their side when landing next to the slot, cross it otherwise.
assert_eq!(a.move_item(3, 2), Some(2));
assert_eq!(a.contents_at(), 2, "stays above");
a.move_item(3, 0);
assert_eq!(a.contents_at(), 3, "crossed below");
a.move_item(0, 3);
assert_eq!(a.contents_at(), 2, "crossed above");
assert_eq!(a.move_item(9, 0), None);
// Saved only when set; older files load with every item above.
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contents_index\":2"));
assert_eq!(serde_json::from_str::<Appearance>(&json).unwrap(), a);
let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap();
assert_eq!(old.contents_index, None);
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
}