use ggplot_rs::prelude::*;
fn render(plot: GGPlot, w: u32, h: u32) -> String {
plot.render_svg_native_with_size(w, h)
.expect("native svg render")
}
fn groups(n: usize) -> Vec<(String, Vec<Value>)> {
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Group {:02}", i + 1)))
.collect();
vec![("x".into(), x), ("y".into(), y), ("g".into(), g)]
}
fn base(n: usize) -> GGPlot {
GGPlot::new(groups(n))
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
}
fn data_plot_rect(svg: &str) -> [f64; 4] {
let start = svg.find("data-plot=\"").expect("data-plot attr") + "data-plot=\"".len();
let rest = &svg[start..];
let end = rest.find('"').expect("closing quote");
let mut it = rest[..end]
.split_whitespace()
.map(|s| s.parse::<f64>().unwrap());
[
it.next().unwrap(),
it.next().unwrap(),
it.next().unwrap(),
it.next().unwrap(),
]
}
fn count_warnings(svg_with_warnings: &[String], needle: &str) -> usize {
svg_with_warnings
.iter()
.filter(|w| w.contains(needle))
.count()
}
#[test]
fn overflowing_bottom_legend_auto_wraps_and_grows_legend_rows() {
const W: u32 = 640;
const H: u32 = 400;
let one_row = render(base(3).legend_position(LegendPosition::Bottom), W, H);
let many = render(base(12).legend_position(LegendPosition::Bottom), W, H);
let rect_one = data_plot_rect(&one_row);
let rect_many = data_plot_rect(&many);
assert!(
rect_many[3] < rect_one[3],
"panel should shrink once the legend wraps: one_row_h={} many_h={}",
rect_one[3],
rect_many[3]
);
for (start, _) in many.match_indices("<text ") {
let tag_end = many[start..].find('>').unwrap() + start;
let tag = &many[start..=tag_end];
if let Some(x_attr) = tag.split("x=\"").nth(1) {
let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
assert!(
x <= W as f64,
"a text element's x={x} overflows the {W}px canvas"
);
}
}
assert!(many.contains(">Group 01<"), "first entry must still render");
assert!(many.contains(">Group 12<"), "last entry must still render");
}
#[test]
fn the_first_entry_is_unaffected_by_wrapping() {
const W: u32 = 640;
const H: u32 = 400;
let wide = render(base(12).legend_position(LegendPosition::Bottom), 2000, H);
let narrow = render(base(12).legend_position(LegendPosition::Bottom), W, H);
let needle = ">Group 01<";
let x_of = |svg: &str| -> f64 {
let end = svg.find(needle).unwrap();
let start = svg[..end].rfind("<text ").unwrap();
let tag = &svg[start..end];
tag.split("x=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
.parse()
.unwrap()
};
assert!((x_of(&wide) - x_of(&narrow)).abs() < 1e-6);
}
#[test]
fn deeply_crowded_legend_ends_in_plus_n_more_with_one_warning() {
let (svg, warnings) = base(30)
.legend_position(LegendPosition::Bottom)
.guides(GuideLegend::new().with_max_rows(2))
.render_svg_native_with_warnings(320, 400)
.expect("render");
assert!(
svg.contains(" more<"),
"expected a \"+N more\" marker in the legend: {svg}"
);
assert_eq!(
count_warnings(&warnings, "entries don't fit the chart"),
1,
"expected exactly one overflow warning: {warnings:?}"
);
}
#[test]
fn explicit_ncol_grid_fills_column_major() {
let svg = render(base(6).guides(GuideLegend::new().with_ncol(3)), 500, 400);
let x_of = |label: &str| -> f64 {
let needle = format!(">{label}<");
let end = svg.find(&needle).unwrap();
let start = svg[..end].rfind("<text ").unwrap();
svg[start..end]
.split("x=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
.parse::<f64>()
.unwrap()
};
let x1 = x_of("Group 01");
let x2 = x_of("Group 02");
let x3 = x_of("Group 03");
assert!((x1 - x2).abs() < 1e-6, "entries 1 and 2 share a column");
assert!((x1 - x3).abs() > 1e-6, "entry 3 starts a new column");
}
#[test]
fn explicit_grid_with_byrow_fills_row_major() {
let svg = render(
base(6).guides(GuideLegend::new().with_ncol(3).with_byrow(true)),
500,
400,
);
let y_of = |label: &str| -> f64 {
let needle = format!(">{label}<");
let end = svg.find(&needle).unwrap();
let start = svg[..end].rfind("<text ").unwrap();
svg[start..end]
.split("y=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
.parse::<f64>()
.unwrap()
};
let y1 = y_of("Group 01");
let y3 = y_of("Group 03");
let y4 = y_of("Group 04");
assert!((y1 - y3).abs() < 1e-6, "entries 1 and 3 share row 0");
assert!((y1 - y4).abs() > 1e-6, "entry 4 starts row 1");
}
#[test]
fn vertical_legend_with_explicit_ncol_two() {
let svg = render(base(4).guides(GuideLegend::new().with_ncol(2)), 500, 400);
assert!(svg.contains(">Group 01<"));
assert!(svg.contains(">Group 04<"));
assert!(
max_text_right_edge(&svg) <= 500.0,
"max text right edge {} exceeds the 500px canvas",
max_text_right_edge(&svg)
);
}
fn text_x(svg: &str, label: &str) -> Option<f64> {
svg.match_indices("<text ").find_map(|(start, _)| {
let tag_end = svg[start..].find('>').map(|e| start + e)?;
let close = svg[tag_end..].find("</text>").map(|e| tag_end + e)?;
if &svg[tag_end + 1..close] != label {
return None;
}
let tag = &svg[start..tag_end];
tag.split("x=\"").nth(1)?.split('"').next()?.parse().ok()
})
}
fn max_text_right_edge(svg: &str) -> f64 {
svg.match_indices("<text ")
.filter_map(|(start, _)| {
let tag_end = svg[start..].find('>').map(|e| start + e)?;
let close = svg[tag_end..].find("</text>").map(|e| tag_end + e)?;
let tag = &svg[start..tag_end];
let mut label = &svg[tag_end + 1..close];
if let Some(title_end) = label.find("</title>") {
label = &label[title_end + "</title>".len()..];
}
let x: f64 = tag.split("x=\"").nth(1)?.split('"').next()?.parse().ok()?;
let size: f64 = tag
.split("font-size=\"")
.nth(1)?
.split('"')
.next()?
.parse()
.ok()?;
let anchor = tag.split("text-anchor=\"").nth(1)?.split('"').next()?;
let w = ggplot_rs::render::text::text_width(label, size);
Some(match anchor {
"start" => x + w,
"middle" => x + w / 2.0,
_ => x,
})
})
.fold(0.0_f64, f64::max)
}
#[test]
fn vertical_legend_ncol_two_with_long_labels_stays_on_canvas() {
let data = groups_with_labels(8, |i| format!("Group Label {}", i + 1));
let svg = render(
GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(2)),
640,
400,
);
let max_x = max_text_right_edge(&svg);
assert!(
max_x <= 640.0,
"max text right edge {max_x} exceeds the 640px canvas"
);
}
#[test]
fn vertical_legend_ncol_grid_too_wide_reduces_columns_instead_of_warning() {
let data = groups_with_labels(12, |i| format!("A Very Long Group Label Number {}", i + 1));
let (svg, warnings) = GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(4))
.render_svg_native_with_warnings(300, 400)
.unwrap();
assert!(!svg.contains("NaN"), "{svg}");
assert!(
warnings
.iter()
.all(|w| !(w.contains("legend") && w.contains("doesn't fit"))),
"column reduction should have avoided the warning: {warnings:?}"
);
}
#[test]
fn legend_panel_width_is_reclaimed_after_column_reduction() {
let data = groups_with_labels(9, |i| format!("Group {}", i + 1));
let (reduced_svg, reduced_warnings) = GGPlot::new(data.clone())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(3))
.render_svg_native_with_warnings(300, 400)
.unwrap();
let (direct_svg, direct_warnings) = GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(2))
.render_svg_native_with_warnings(300, 400)
.unwrap();
assert!(
reduced_warnings.is_empty() && direct_warnings.is_empty(),
"neither grid should need to warn: reduced={reduced_warnings:?} direct={direct_warnings:?}"
);
assert!(!reduced_svg.contains('…'), "{reduced_svg}");
let reduced_width = data_plot_rect(&reduced_svg)[2];
let direct_width = data_plot_rect(&direct_svg)[2];
assert!(
(reduced_width - direct_width).abs() < 0.01,
"#168: a ncol(3) grid auto-reduced to 2 columns must reserve the same \
width as an explicit ncol(2) grid (panel width {reduced_width}), not \
the wider, stale ncol(3) reservation (direct ncol(2) panel width \
{direct_width})"
);
}
#[test]
fn legend_panel_width_reservation_is_not_loosened_when_unsafe() {
let data = groups_with_labels(12, |i| format!("A Very Long Group Label Number {}", i + 1));
let (reduced_svg, _) = GGPlot::new(data.clone())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(4))
.render_svg_native_with_warnings(300, 400)
.unwrap();
let (direct_one_col_svg, _) = GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(1))
.render_svg_native_with_warnings(300, 400)
.unwrap();
let reduced_width = data_plot_rect(&reduced_svg)[2];
let direct_width = data_plot_rect(&direct_one_col_svg)[2];
assert!(
reduced_width < direct_width,
"retightening to the fully-reduced 1-column reservation would run this \
label off the canvas (an explicit ncol(1) grid has no fit check to \
catch it) — the wider reservation must be kept: reduced={reduced_width} \
direct_ncol1={direct_width}"
);
}
fn two_discrete_guides_with_labels(
n_color: usize,
color_label: impl Fn(usize) -> String,
n_shape: usize,
shape_label: impl Fn(usize) -> String,
) -> Vec<(String, Vec<Value>)> {
let n = n_color.max(n_shape);
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(color_label(i % n_color)))
.collect();
let s: Vec<Value> = (0..n)
.map(|i| Value::Str(shape_label(i % n_shape)))
.collect();
vec![
("x".into(), x),
("y".into(), y),
("g".into(), g),
("s".into(), s),
]
}
#[test]
fn two_guides_ncol_retighten_repro_a_stays_on_canvas() {
let data = two_discrete_guides_with_labels(
6,
|i| format!("Medium Colour Name {:02}", i + 1),
3,
|i| format!("Shape With A Really Long Label {:02}", i + 1),
);
let svg = render(
GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g").shape("s"))
.geom_point()
.legend_position(LegendPosition::Right)
.guides(GuideLegend::new().with_ncol(2)),
380,
400,
);
let max_x = max_text_right_edge(&svg);
assert!(
max_x <= 380.0,
"the #168 retry must re-check every guide, not just the one(s) \
reduced — an untouched guide's own plan still shifts with the \
shared reservation: right edge {max_x}"
);
}
#[test]
fn two_guides_ncol_retighten_repro_b_stays_on_canvas() {
let data = two_discrete_guides_with_labels(
6,
|i| format!("Colour {:02}", i + 1),
3,
|i| format!("An Extremely Long Shape Label That Goes On {:02}", i + 1),
);
let svg = render(
GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g").shape("s"))
.geom_point()
.legend_position(LegendPosition::Right)
.guides(GuideLegend::new().with_ncol(3)),
330,
400,
);
let max_x = max_text_right_edge(&svg);
assert!(
max_x <= 330.0,
"the #168 retry must re-check every guide, including a truncated \
one whose label_override depends on the old legend_x: right edge {max_x}"
);
}
#[test]
fn vertical_legend_with_very_long_labels_truncates_with_a_title_tooltip() {
let data = groups_with_labels(12, |i| {
format!(
"An Extremely Very Super Duper Long Group Label Number {} \
With Lots More Text Appended Here To Make It Even Longer",
i + 1
)
});
let (svg, warnings) = GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(6))
.render_svg_native_with_warnings(200, 400)
.unwrap();
assert!(!svg.contains("NaN"), "{svg}");
assert!(
svg.contains('…'),
"a label should have been truncated: {svg}"
);
assert!(
svg.contains("<title>An Extremely Very Super Duper Long Group Label Number 1 "),
"the full label should still be available via <title>: {svg}"
);
assert!(
warnings
.iter()
.all(|w| !(w.contains("legend") && w.contains("doesn't fit"))),
"truncation should have avoided the warning: {warnings:?}"
);
}
#[test]
fn vertical_legend_warns_only_when_truncation_still_cant_fit() {
let data = groups_with_labels(12, |i| {
format!(
"An Extremely Very Super Duper Long Group Label Number {} \
With Lots More Text Appended Here To Make It Even Longer",
i + 1
)
});
let (svg, warnings) = GGPlot::new(data)
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_ncol(8))
.render_svg_native_with_warnings(60, 400)
.unwrap();
assert!(!svg.contains("NaN"), "{svg}");
assert!(
warnings
.iter()
.any(|w| w.contains("legend") && w.contains("doesn't fit")),
"{warnings:?}"
);
}
fn groups_with_labels(n: usize, label: impl Fn(usize) -> String) -> Vec<(String, Vec<Value>)> {
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n).map(label).map(Value::Str).collect();
vec![("x".into(), x), ("y".into(), y), ("g".into(), g)]
}
#[test]
fn invalid_grid_settings_warn_and_fall_back() {
let (_, w1) = base(6)
.guides(GuideLegend::new().with_nrow(0))
.render_svg_native_with_warnings(500, 400)
.unwrap();
assert!(
w1.iter().any(|w| w.contains("nrow(0)")),
"nrow(0) should warn: {w1:?}"
);
let (_, w2) = base(6)
.guides(GuideLegend::new().with_nrow(2).with_ncol(2)) .render_svg_native_with_warnings(500, 400)
.unwrap();
assert!(
w2.iter().any(|w| w.contains("smaller than 6 keys")),
"nrow*ncol < n should warn: {w2:?}"
);
let (_, w3) = base(6)
.guides(GuideLegend::new().with_max_rows(0))
.legend_position(LegendPosition::Bottom)
.render_svg_native_with_warnings(150, 400)
.unwrap();
assert!(
w3.iter().any(|w| w.contains("max_rows(0)")),
"max_rows(0) should warn: {w3:?}"
);
}
#[test]
fn byte_identical_cases_are_unaffected() {
for pos in [LegendPosition::Right, LegendPosition::Bottom] {
let svg = render(base(3).legend_position(pos), 800, 400);
assert!(!svg.contains(" more<"), "a 3-key legend never overflows");
}
}
#[test]
fn a_vertical_grid_that_already_fits_is_byte_identical() {
let (svg1, warnings1) = base(4)
.guides(GuideLegend::new().with_ncol(2))
.render_svg_native_with_warnings(500, 400)
.unwrap();
let (svg2, warnings2) = base(4)
.guides(GuideLegend::new().with_ncol(2))
.render_svg_native_with_warnings(500, 400)
.unwrap();
assert_eq!(svg1, svg2, "identical input must render identically");
assert!(
!svg1.contains('…'),
"a fitting grid must not truncate: {svg1}"
);
assert!(
warnings1.is_empty() && warnings2.is_empty(),
"a fitting grid must not warn: {warnings1:?}"
);
}
fn drawn_plus_n(svg: &str) -> Option<usize> {
let start = svg.find(" more<")?;
let seg = &svg[..start];
let open = seg.rfind('>')? + 1;
seg[open..].trim_start_matches('+').parse().ok()
}
fn warned_n(warnings: &[String]) -> Option<usize> {
warnings.iter().find_map(|w| {
let rest = w.strip_prefix("legend: ")?;
let k = rest.split(' ').next()?;
k.parse().ok()
})
}
#[test]
fn drawn_plus_n_always_matches_the_warned_n_across_heights_and_counts() {
let mut checked_some_overflow = false;
for h in [120u32, 200, 320, 400, 480, 600] {
for n in [20usize, 40, 80] {
let (svg, warnings) = base(n)
.legend_position(LegendPosition::Bottom)
.render_svg_native_with_warnings(320, h)
.unwrap();
let warned = warned_n(&warnings);
let drawn = drawn_plus_n(&svg);
assert_eq!(
warned, drawn,
"h={h} n={n}: warned {warned:?} but drew {drawn:?}"
);
if warned.is_some() {
checked_some_overflow = true;
}
}
}
assert!(
checked_some_overflow,
"the scan should hit at least one overflowing combination"
);
}
#[test]
fn reserved_band_height_matches_the_drawn_row_count() {
let n = 40;
let h = 320u32;
let svg = render(base(n).legend_position(LegendPosition::Bottom), 320, h);
let canvas_h = h as f64;
for (start, _) in svg.match_indices("<rect ") {
let tag_end = svg[start..].find('/').map(|e| start + e).unwrap_or(start);
let tag = &svg[start..tag_end];
if !tag.contains("width=\"12.00\"") {
continue; }
let y: f64 = tag
.split("y=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
assert!(
y <= canvas_h,
"a legend key at y={y} falls below the {canvas_h}px canvas — reserved band too short"
);
}
}
#[test]
fn two_discrete_guides_each_auto_wrap() {
let n = 10;
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Colour {:02}", i + 1)))
.collect();
let s: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Shape {:02}", i + 1)))
.collect();
let plot = GGPlot::new(vec![
("x".into(), x),
("y".into(), y),
("g".into(), g),
("s".into(), s),
])
.aes(Aes::new().x("x").y("y").color("g").shape("s"))
.geom_point()
.legend_position(LegendPosition::Bottom);
let svg = render(plot, 640, 500);
assert!(svg.contains(">Colour 01<"), "{svg}");
assert!(svg.contains(">Colour 10<"), "{svg}");
assert!(svg.contains(">Shape 01<"), "{svg}");
assert!(svg.contains(">Shape 10<"), "{svg}");
assert!(
max_text_right_edge(&svg) <= 640.0,
"a guide's wrapped text overflows the 640px canvas: right edge {}",
max_text_right_edge(&svg)
);
}
#[test]
fn plotgrid_collected_legend_auto_wraps() {
use ggplot_rs::compose::{GridLegendPosition, PlotGrid};
let n = 14;
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Group {:02}", i + 1)))
.collect();
let plot = GGPlot::new(vec![("x".into(), x), ("y".into(), y), ("g".into(), g)])
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point();
let svg = PlotGrid::new()
.add(plot)
.collect_legends(true)
.legend_position(GridLegendPosition::Bottom)
.render_svg_native_with_size(500, 400)
.expect("render");
assert!(svg.contains(">Group 01<"), "{svg}");
assert!(svg.contains(">Group 14<"), "{svg}");
assert!(
max_text_right_edge(&svg) <= 500.0,
"the collected legend's text overflows the 500px canvas: right edge {}",
max_text_right_edge(&svg)
);
}
#[test]
fn plotgrid_collected_legend_two_guides_sit_side_by_side() {
use ggplot_rs::compose::{GridLegendPosition, PlotGrid};
let n = 8;
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Colour {:02}", i + 1)))
.collect();
let s: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Shape {:02}", i + 1)))
.collect();
let plot = GGPlot::new(vec![
("x".into(), x),
("y".into(), y),
("g".into(), g),
("s".into(), s),
])
.aes(Aes::new().x("x").y("y").color("g").shape("s"))
.geom_point();
let svg = PlotGrid::new()
.add(plot)
.collect_legends(true)
.legend_position(GridLegendPosition::Bottom)
.render_svg_native_with_size(500, 400)
.expect("render");
let colour_x = text_x(&svg, "Colour 01").expect("colour guide label \"Colour 01\" missing");
let shape_x = text_x(&svg, "Shape 01").expect("shape guide label \"Shape 01\" missing");
assert!(
shape_x > colour_x,
"the shape guide (x={shape_x}) must sit to the right of the colour guide \
(x={colour_x}), not on top of it"
);
assert!(svg.contains(">Colour 08<"), "{svg}");
assert!(svg.contains(">Shape 08<"), "{svg}");
}
#[test]
fn bottom_legend_colourbar_leaves_room_for_the_discrete_guide() {
let n = 18;
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let y: Vec<Value> = (0..n).map(|i| Value::Float((i % 3) as f64)).collect();
let c: Vec<Value> = (0..n).map(|i| Value::Float(i as f64)).collect();
let s: Vec<Value> = (0..n)
.map(|i| Value::Str(format!("Shape Group Number {:02}", i + 1)))
.collect();
let plot = GGPlot::new(vec![
("x".into(), x),
("y".into(), y),
("c".into(), c),
("s".into(), s),
])
.aes(Aes::new().x("x").y("y").color("c").shape("s"))
.geom_point()
.legend_position(LegendPosition::Bottom);
let svg = render(plot, 450, 500);
assert!(svg.contains(">Shape Group Number 01<"), "{svg}");
let max_x = max_text_right_edge(&svg);
assert!(
max_x <= 450.0,
"the discrete guide's wrapped text overflows the 450px canvas once the \
colourbar's own share is accounted for: right edge {max_x}"
);
}
#[test]
fn plotgrid_collected_legend_propagates_its_own_warnings() {
use ggplot_rs::compose::{GridLegendPosition, PlotGrid};
let plot = GGPlot::new(groups(6))
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.guides(GuideLegend::new().with_nrow(0));
let (_, warnings) = PlotGrid::new()
.add(plot)
.collect_legends(true)
.legend_position(GridLegendPosition::Bottom)
.render_svg_native_with_warnings(500, 400)
.expect("render");
assert!(
warnings.iter().any(|w| w.contains("nrow(0)")),
"the collected legend's own guide_legend warning should have reached \
the grid's warnings: {warnings:?}"
);
}