use hephaestus::brush::Brush;
use hephaestus::color::{rgb, Color};
use hephaestus::composition::{beside, Composition, Patch};
use hephaestus::geometry::Size;
use hephaestus::plot::chrome::axis::{Axis, AxisPlacement};
use hephaestus::plot::theme::{
Element, Length, Locale, Palette, RectElement, Theme, ThemeColor, ThemePart,
};
use hephaestus::plot::{scale, Plot, PlotComposition, PointGeom};
use hephaestus::scales::chrome::AxisSide;
use hephaestus::scene::recording::{Op, RecordingScene};
const W: f64 = 400.0;
const H: f64 = 300.0;
const DPI: f64 = 96.0;
fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
fn solid_color(brush: &Brush) -> Option<Color> {
match brush {
Brush::Solid(c) => Some(*c),
_ => None,
}
}
fn color_eq(actual: Color, expected: Color, eps: f32) -> bool {
let [r1, g1, b1, a1] = actual.components;
let [r2, g2, b2, a2] = expected.components;
approx_eq(r1, r2, eps)
&& approx_eq(g1, g2, eps)
&& approx_eq(b1, b2, eps)
&& approx_eq(a1, a2, eps)
}
fn any_fill_with_color(ops: &[Op], target: Color, eps: f32) -> bool {
ops.iter().any(|op| match op {
Op::Fill { brush, .. } => solid_color(brush)
.map(|c| color_eq(c, target, eps))
.unwrap_or(false),
_ => false,
})
}
fn any_stroke_with_color(ops: &[Op], target: Color, eps: f32) -> bool {
ops.iter().any(|op| match op {
Op::Stroke { brush, .. } => solid_color(brush)
.map(|c| color_eq(c, target, eps))
.unwrap_or(false),
_ => false,
})
}
fn single_plot_view(theme: Theme) -> (PlotComposition, RecordingScene) {
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0));
let dummy_template: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy_template, "p")
.bind("x", "x")
.bind("y", "y");
p.add_geom(
PointGeom::builder()
.set("x", vec![0.25_f64, 0.5, 0.75])
.set("y", vec![0.25_f64, 0.5, 0.75])
.build(),
);
p.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)));
p.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left)));
view.attach_plot(p);
let scene = RecordingScene::default();
(view, scene)
}
fn render_view(view: &mut PlotComposition, scene: &mut RecordingScene) {
view.render(scene, Size::new(W, H), DPI);
}
fn solid_red_panel_theme() -> Theme {
Theme {
panel_background: Element::Set(RectElement {
fill: Some(ThemeColor::Fixed(rgb(1.0, 0.0, 0.0))),
color: None,
linewidth_pt: Some(Length::Abs(0.0)),
..RectElement::default()
}),
..Theme::default()
}
}
#[test]
fn default_theme_plot_background_uses_paper() {
let theme = Theme::default();
let paper = theme.palette.paper;
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_fill_with_color(&scene.ops, paper, 1e-4),
"expected a fill in palette.paper (white) for the plot background"
);
}
#[test]
fn default_theme_panel_background_uses_grey92() {
let theme = Theme::default();
let expected =
ThemeColor::mix(ThemeColor::Paper, ThemeColor::Ink, 0.08).resolve(&theme.palette);
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_fill_with_color(&scene.ops, expected, 1e-4),
"expected the panel background to be drawn in grey92"
);
}
#[test]
fn default_theme_panel_border_blank_emits_no_panel_outline_stroke() {
let theme = Theme::default();
let ink = theme.palette.ink;
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
let any_ink_1pt = scene.ops.iter().any(|op| match op {
Op::Stroke { brush, stroke, .. } => {
let one_px = (DPI / 72.0) as f32;
solid_color(brush)
.map(|c| color_eq(c, ink, 1e-4))
.unwrap_or(false)
&& approx_eq(stroke.width as f32, one_px, 0.5)
}
_ => false,
});
assert!(
!any_ink_1pt,
"panel_border = Blank must not emit an ink 1pt stroke around the panel"
);
}
#[test]
fn default_theme_grids_stroke_in_paper() {
let theme = Theme::default();
let paper = theme.palette.paper;
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_stroke_with_color(&scene.ops, paper, 1e-4),
"expected grid lines drawn in palette.paper (white)"
);
}
#[test]
fn default_axis_baseline_blank_emits_no_baseline_stroke() {
let theme = Theme::default();
let ink = theme.palette.ink;
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
let ink_strokes = scene
.ops
.iter()
.filter(|op| match op {
Op::Stroke { brush, .. } => solid_color(brush)
.map(|c| color_eq(c, ink, 1e-4))
.unwrap_or(false),
_ => false,
})
.count();
assert_eq!(
ink_strokes, 0,
"expected zero pure-ink strokes — axis baseline + panel border are both Blank"
);
}
#[test]
fn default_axis_ticks_stroke_in_grey20() {
let theme = Theme::default();
let grey20 = ThemeColor::mix(ThemeColor::Paper, ThemeColor::Ink, 0.8).resolve(&theme.palette);
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_stroke_with_color(&scene.ops, grey20, 1e-4),
"expected axis ticks drawn in grey20"
);
}
#[test]
fn theme_override_replaces_panel_background_fill() {
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(Theme::default())
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0));
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let red = rgb(1.0, 0.0, 0.0);
let mut p = Plot::new(&dummy, "p")
.bind("x", "x")
.bind("y", "y")
.theme_override(ThemePart {
panel_background: Some(Element::Set(RectElement {
fill: Some(ThemeColor::Fixed(red)),
color: None,
linewidth_pt: Some(Length::Abs(0.0)),
..RectElement::default()
})),
..ThemePart::default()
});
p.add_geom(
PointGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build(),
);
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
assert!(
any_fill_with_color(&scene.ops, red, 1e-4),
"theme_override on panel_background must override the composition theme"
);
}
#[test]
fn theme_invert_swaps_paper_and_ink_in_render() {
let theme = Theme::default().invert();
let inverted_paper = theme.palette.paper; let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_fill_with_color(&scene.ops, inverted_paper, 1e-4),
"plot background must follow the inverted palette anchor"
);
}
#[test]
fn custom_palette_propagates_through_theme_anchors() {
let palette = Palette::new(
rgb(0.10, 0.20, 0.40), rgb(0.95, 0.95, 0.90), rgb(0.95, 0.65, 0.20), );
let theme = Theme::default().with_palette(palette);
let paper = theme.palette.paper;
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
any_fill_with_color(&scene.ops, paper, 1e-4),
"plot background must use the swapped paper colour"
);
}
#[test]
fn solid_panel_override_renders_as_one_fill() {
let theme = solid_red_panel_theme();
let red = rgb(1.0, 0.0, 0.0);
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
let red_fills = scene
.ops
.iter()
.filter(|op| match op {
Op::Fill { brush, .. } => solid_color(brush)
.map(|c| color_eq(c, red, 1e-4))
.unwrap_or(false),
_ => false,
})
.count();
assert!(
red_fills >= 1,
"expected at least one red panel fill, found {red_fills}"
);
assert!(
!any_stroke_with_color(&scene.ops, red, 1e-4),
"linewidth_pt = 0 must suppress the red border stroke"
);
}
#[test]
fn strip_text_blank_suppresses_whole_strip() {
let strip_bg_color = rgb(0.9, 0.2, 0.2);
let theme = Theme {
strip_text: hephaestus::plot::theme::Sided {
all: Element::Blank,
by_channel: [Element::Inherit, Element::Inherit],
by_channel_side: [
[Element::Inherit, Element::Inherit],
[Element::Inherit, Element::Inherit],
],
},
strip_background: hephaestus::plot::theme::Sided::new(RectElement {
fill: Some(ThemeColor::Fixed(strip_bg_color)),
color: None,
linewidth_pt: Some(Length::Abs(0.0)),
..RectElement::default()
}),
..Theme::default()
};
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0));
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy, "p")
.bind("x", "x")
.bind("y", "y")
.strip(AxisSide::Top, "Facet")
.strip(AxisSide::Right, "Group");
p.add_geom(
PointGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build(),
);
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
assert!(
!any_fill_with_color(&scene.ops, strip_bg_color, 1e-4),
"strip_text = Blank must suppress the strip background even when a label is installed"
);
}
#[test]
fn legend_key_frame_uses_theme_swatch_color() {
use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec};
use hephaestus::scales::value::Value;
use std::sync::Arc;
let theme = Theme::default();
let grey92 = ThemeColor::mix(ThemeColor::Paper, ThemeColor::Ink, 0.08).resolve(&theme.palette);
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0))
.add_scale(
"cat",
scale::discrete([Value::String(Arc::from("a")), Value::String(Arc::from("b"))])
.range_colors([rgb(1.0, 0.0, 0.0), rgb(0.0, 1.0, 0.0)]),
);
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy, "p").bind("x", "x").bind("y", "y");
p.add_geom(
PointGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build(),
);
p.add_legend(Legend::new("cat").key(LegendKeySpec::rect().scaled("fill", "cat")));
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
let swatch_fills = scene
.ops
.iter()
.filter(|op| match op {
Op::Fill { brush, .. } => solid_color(brush)
.map(|c| color_eq(c, grey92, 1e-4))
.unwrap_or(false),
_ => false,
})
.count();
assert!(
swatch_fills >= 2,
"expected at least 2 grey92 swatch fills (one per legend key), found {swatch_fills}"
);
}
#[test]
fn locale_de_renders_decimal_comma_in_axis_labels() {
let theme = Theme::default().with_locale(Locale::DE_DE);
let s = scale::continuous(0.0..=1.0);
let breaks = s.breaks(hephaestus::scales::DEFAULT_BREAK_COUNT);
let labels: Vec<String> = breaks.iter().map(|v| s.format(v, &theme.locale)).collect();
assert!(
labels.iter().any(|l| l.contains(',')),
"expected at least one DE_DE label with a decimal comma; got {labels:?}"
);
assert!(
!labels.iter().any(|l| l.contains('.')),
"DE_DE labels must not contain decimal points; got {labels:?}"
);
}
#[test]
fn locale_default_renders_decimal_point() {
let theme = Theme::default();
let s = scale::continuous(0.0..=1.0);
let breaks = s.breaks(hephaestus::scales::DEFAULT_BREAK_COUNT);
let labels: Vec<String> = breaks.iter().map(|v| s.format(v, &theme.locale)).collect();
assert!(
!labels.iter().any(|l| l.contains(',')),
"EN_US labels must not contain decimal commas; got {labels:?}"
);
}
#[test]
fn user_formatter_receives_locale() {
let s = scale::continuous(0.0..=1.0).with_format(|v, locale| match v {
hephaestus::scales::Value::Number(n) => {
format!(
"{n:.1}{}",
if locale.decimal == ',' { " DE" } else { " US" }
)
}
other => hephaestus::plot::Scale::default_format(other, locale),
});
assert!(s
.format(&hephaestus::scales::Value::Number(0.5), &Locale::EN_US)
.ends_with(" US"));
assert!(s
.format(&hephaestus::scales::Value::Number(0.5), &Locale::DE_DE)
.ends_with(" DE"));
}
fn stroked_glyph_runs_with_color(ops: &[Op], target: Color, eps: f32) -> usize {
ops.iter()
.filter(|op| match op {
Op::DrawGlyphs(g) => {
g.style.is_some()
&& solid_color(&g.brush)
.map(|c| color_eq(c, target, eps))
.unwrap_or(false)
}
_ => false,
})
.count()
}
fn any_stroked_glyph_run(ops: &[Op]) -> bool {
ops.iter()
.any(|op| matches!(op, Op::DrawGlyphs(g) if g.style.is_some()))
}
fn outlined_text(color: Color) -> hephaestus::plot::theme::TextElement {
hephaestus::plot::theme::TextElement {
text_stroke: Some(ThemeColor::Fixed(color)),
text_linewidth_pt: Some(Length::Abs(1.5)),
..hephaestus::plot::theme::TextElement::default()
}
}
#[test]
fn default_theme_emits_no_stroked_glyph_runs() {
let (mut view, mut scene) = single_plot_view(Theme::default());
render_view(&mut view, &mut scene);
assert!(
!any_stroked_glyph_run(&scene.ops),
"default theme must not outline any chrome text"
);
}
#[test]
fn axis_text_outline_reaches_the_scene() {
let blue = rgb(0.0, 0.0, 1.0);
let mut theme = Theme::default();
theme.axis.all.text = Element::Set(outlined_text(blue));
let (mut view, mut scene) = single_plot_view(theme);
render_view(&mut view, &mut scene);
assert!(
stroked_glyph_runs_with_color(&scene.ops, blue, 1e-4) > 0,
"themed axis tick-label outline should emit stroked glyph runs"
);
}
#[test]
fn axis_title_outline_reaches_the_scene() {
let blue = rgb(0.0, 0.0, 1.0);
let mut theme = Theme::default();
theme.axis.all.title = Element::Set(outlined_text(blue));
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0));
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy, "p").bind("x", "x").bind("y", "y");
p.add_geom(
PointGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build(),
);
p.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)).title("X"));
p.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left)).title("Y"));
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
assert!(
stroked_glyph_runs_with_color(&scene.ops, blue, 1e-4) > 0,
"themed axis-title outline should emit stroked glyph runs"
);
}
#[test]
fn plot_title_outline_reaches_the_scene() {
let blue = rgb(0.0, 0.0, 1.0);
let theme = Theme {
plot_title: Element::Set(outlined_text(blue)),
..Theme::default()
};
let (mut view, mut scene) = single_plot_view(theme);
view.update_plot("p", |p| p.set_title("Outlined title"));
render_view(&mut view, &mut scene);
assert!(
stroked_glyph_runs_with_color(&scene.ops, blue, 1e-4) > 0,
"themed plot-title outline should emit stroked glyph runs"
);
}
#[test]
fn legend_title_outline_reaches_the_scene() {
use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec};
use hephaestus::scales::value::Value;
use std::sync::Arc;
let blue = rgb(0.0, 0.0, 1.0);
let mut theme = Theme::default();
theme.legend.title = Element::Set(outlined_text(blue));
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("x", scale::continuous(0.0..=1.0))
.add_scale("y", scale::continuous(0.0..=1.0))
.add_scale(
"cat",
scale::discrete([Value::String(Arc::from("a")), Value::String(Arc::from("b"))])
.range_colors([rgb(1.0, 0.0, 0.0), rgb(0.0, 1.0, 0.0)]),
);
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy, "p").bind("x", "x").bind("y", "y");
p.add_geom(
PointGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.build(),
);
p.add_legend(
Legend::new("cat")
.title("Category")
.key(LegendKeySpec::rect().scaled("fill", "cat")),
);
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
assert!(
stroked_glyph_runs_with_color(&scene.ops, blue, 1e-4) > 0,
"themed legend-title outline should emit stroked glyph runs"
);
}
#[test]
fn polar_axis_title_outline_reaches_the_scene() {
use hephaestus::plot::chrome::axis::PolarRing;
use hephaestus::plot::projection::{PolarProjection, Projection};
let blue = rgb(0.0, 0.0, 1.0);
let mut theme = Theme::default();
theme.axis.all.title = Element::Set(outlined_text(blue));
let template = beside(Patch::new("p"), Patch::new("__pad"));
let mut view = PlotComposition::new(&template)
.theme(theme)
.add_scale("theta", scale::continuous(0.0..=1.0))
.add_scale("r", scale::continuous(0.0..=1.0));
let dummy: Composition = beside(Patch::new("p"), Patch::new("__pad"));
let mut p = Plot::new(&dummy, "p")
.bind("x", "theta")
.bind("y", "r")
.projection(Projection::Polar(PolarProjection::full_circle()));
p.add_geom(
PointGeom::builder()
.set("x", vec![0.25_f64, 0.75])
.set("y", vec![0.5_f64, 0.9])
.build(),
);
p.add_axis(Axis::rail("theta", AxisPlacement::PolarAngular(PolarRing::Outer)).title("Theta"));
p.add_axis(Axis::rail("r", AxisPlacement::PolarRadius { theta_frac: 0.0 }).title("Radius"));
view.attach_plot(p);
let mut scene = RecordingScene::default();
render_view(&mut view, &mut scene);
assert!(
stroked_glyph_runs_with_color(&scene.ops, blue, 1e-4) > 0,
"themed polar axis-title outline should emit stroked glyph runs"
);
}