Skip to main content

ggplot_rs/geom/
bracket.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::{DataFrame, Value};
4use crate::position::identity::PositionIdentity;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, FontFace, LineStyle, Linetype, TextAnchor, TextStyle};
7use crate::render::RenderError;
8use crate::scale::ScaleSet;
9use crate::stat::identity::StatIdentity;
10use crate::stat::Stat;
11use crate::theme::Theme;
12
13use super::{Geom, GeomParams};
14
15/// Significance bracket — a horizontal bar with two downward end ticks spanning
16/// `[xmin, xmax]` at height `y`, captioned with a `label` above it (R's
17/// `ggpubr::geom_bracket`). Typically annotates a pairwise-comparison p-value or
18/// significance stars over a boxplot.
19pub struct GeomBracket {
20    pub color: (u8, u8, u8),
21    pub line_width: f64,
22    /// Length of the downward end ticks, in pixels.
23    pub tip_length: f64,
24    /// Label font size.
25    pub label_size: f64,
26}
27
28impl Default for GeomBracket {
29    fn default() -> Self {
30        GeomBracket {
31            color: (0, 0, 0),
32            line_width: 1.0,
33            tip_length: 8.0,
34            label_size: 12.0,
35        }
36    }
37}
38
39impl Geom for GeomBracket {
40    fn draw(
41        &self,
42        data: &DataFrame,
43        coord: &dyn Coord,
44        scales: &ScaleSet,
45        theme: &Theme,
46        backend: &mut dyn DrawBackend,
47    ) -> Result<(), RenderError> {
48        let xmin_col = data
49            .column("xmin")
50            .ok_or(RenderError::MissingAesthetic("xmin".into()))?;
51        let xmax_col = data
52            .column("xmax")
53            .ok_or(RenderError::MissingAesthetic("xmax".into()))?;
54        let y_col = data
55            .column("y")
56            .ok_or(RenderError::MissingAesthetic("y".into()))?;
57        let label_col = data.column("label");
58
59        let plot_area = backend.plot_area();
60        let x_scale = scales.get(&Aesthetic::X);
61        let y_scale = scales.get(&Aesthetic::Y);
62
63        for i in 0..data.nrows() {
64            let nxmin = x_scale.map(|s| s.map(&xmin_col[i])).unwrap_or(0.0);
65            let nxmax = x_scale.map(|s| s.map(&xmax_col[i])).unwrap_or(0.0);
66            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
67
68            let (px_min, py) = coord.transform((nxmin, ny), &plot_area);
69            let (px_max, _) = coord.transform((nxmax, ny), &plot_area);
70
71            // Host hover metadata: the label as tooltip, the comparison (or
72            // its `test_id`) as series, the p-value as value.
73            let label_text = label_col.map(|lc| lc[i].to_group_key()).unwrap_or_default();
74            let comparison = format!(
75                "{} vs {}",
76                super::tip_value(&xmin_col[i]),
77                super::tip_value(&xmax_col[i])
78            );
79            let series = data
80                .column(BRACKET_SERIES_COL)
81                .and_then(|c| c.get(i))
82                .filter(|v| !v.is_na())
83                .map(super::tip_value)
84                .unwrap_or_else(|| comparison.clone());
85            let value = data
86                .column(BRACKET_P_COL)
87                .and_then(|c| c.get(i))
88                .and_then(super::raw_value);
89            let tooltip = if label_text.is_empty() {
90                comparison.clone()
91            } else {
92                format!("{comparison}: {label_text}")
93            };
94            super::set_mark(
95                backend,
96                Some(tooltip),
97                Some(comparison),
98                Some(series),
99                value,
100            );
101            // No scale backs the bracket's p-value column: nothing
102            // meaningful to format it through, so just reset (no leak from
103            // an earlier layer).
104            super::set_mark_formatted(backend, scales, None, &Aesthetic::Y, None);
105
106            // Bar at `py` with end ticks pointing toward the data (+y is down in
107            // screen space, so the ticks drop below the bar).
108            let tip = self.tip_length;
109            backend.draw_line(
110                &[
111                    (px_min, py + tip),
112                    (px_min, py),
113                    (px_max, py),
114                    (px_max, py + tip),
115                ],
116                &LineStyle {
117                    color: self.color,
118                    alpha: 1.0,
119                    width: self.line_width,
120                    linetype: Linetype::Solid,
121                    ..Default::default()
122                },
123            )?;
124            super::clear_mark(backend);
125
126            // Centered label just above the bar.
127            if let Some(lc) = label_col {
128                let text = lc[i].to_group_key();
129                if !text.is_empty() {
130                    let cx = (px_min + px_max) / 2.0;
131                    backend.draw_text(
132                        &text,
133                        (cx, py - self.label_size * 0.3 - 2.0),
134                        &TextStyle {
135                            color: self.color,
136                            size: self.label_size,
137                            anchor: TextAnchor::Middle,
138                            angle: 0.0,
139                            family: super::resolve_font_family("", theme),
140                            face: FontFace::Plain,
141                            bg_colour: None,
142                            bg_r: 0.0,
143                        },
144                    )?;
145                }
146            }
147        }
148
149        Ok(())
150    }
151
152    fn required_aes(&self) -> Vec<Aesthetic> {
153        vec![Aesthetic::Xmin, Aesthetic::Xmax, Aesthetic::Y]
154    }
155
156    fn default_stat(&self) -> Box<dyn Stat> {
157        Box::new(StatIdentity)
158    }
159    fn default_position(&self) -> Box<dyn Position> {
160        Box::new(PositionIdentity)
161    }
162    fn default_params(&self) -> GeomParams {
163        GeomParams::default()
164    }
165    fn name(&self) -> &str {
166        "bracket"
167    }
168
169    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
170        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
171            self.color = ink;
172        }
173    }
174
175    fn set_series_color(&mut self, color: (u8, u8, u8)) {
176        self.color = color;
177    }
178}
179
180/// Layer column carrying each bracket's p-value (for `data-value`).
181pub(crate) const BRACKET_P_COL: &str = ".bracket_p";
182/// Layer column carrying each bracket's series key (`test_id`, for
183/// `data-series`).
184pub(crate) const BRACKET_SERIES_COL: &str = ".bracket_series";
185
186/// Significance brackets from a **precomputed** test table — ggpubr's
187/// `stat_pvalue_manual()` / `geom_bracket(data = test_table)`. Nothing is
188/// recomputed: every bracket comes from one table row.
189///
190/// The defaults read the anofox `test` contract schema (`group1`, `group2`,
191/// `p_adj`, `p_value`, `test_id`, …) and the ggpubr/rstatix columns
192/// `y_position` / `label`:
193///
194/// - **groups**: `group1` / `group2` name the x categories the bracket spans.
195///   Rows with a missing group, or a group that is not a level of the plot's
196///   x column, are dropped with a warning.
197/// - **p-value**: per row, `p_adj` when present and not `NULL`, else
198///   `p_value` (override with [`p_column`](Self::p_column)).
199/// - **label**: the [`label`](Self::label) template if set; otherwise a
200///   non-null `label` column; otherwise `"p = {p}"`.
201/// - **height**: a finite `y_position` is used as-is; rows without one are
202///   stacked automatically above the data (and above any given positions),
203///   `step_increase` × the data's y range apart.
204///
205/// Label templates substitute `{column}` with that row's value. Special keys:
206/// `{p}` (the chosen p-value), `{p.signif}` / `{stars}` (significance stars by
207/// [`cutpoints`](Self::cutpoints)) and `{p.format}` (`"p = 0.0123"`-style).
208/// Numeric columns whose name starts with `p` are printed like p-values
209/// (4 significant digits, scientific below 1e-4); other values as in
210/// tooltips. Unknown keys are left verbatim; `NULL` prints as `NA`.
211///
212/// ```
213/// use ggplot_rs::prelude::*;
214/// use ggplot_rs::data::Value;
215/// let tests = vec![
216///     ("group1".to_string(), vec![Value::Str("a".into()), Value::Str("a".into())]),
217///     ("group2".to_string(), vec![Value::Str("b".into()), Value::Str("c".into())]),
218///     ("p_adj".to_string(), vec![Value::Float(0.003), Value::Float(0.2)]),
219/// ];
220/// let data = vec![
221///     ("g".to_string(), ["a", "b", "c"].iter().map(|s| Value::Str(s.to_string())).collect()),
222///     ("y".to_string(), vec![Value::Float(1.0), Value::Float(2.0), Value::Float(3.0)]),
223/// ];
224/// let svg = GGPlot::new(data)
225///     .aes(Aes::new().x("g").y("y"))
226///     .geom_point()
227///     .geom_bracket_table(tests, BracketTable::new().label("p = {p_adj} {stars}"))
228///     .render_svg_native()
229///     .unwrap();
230/// assert!(svg.contains("p = 0.003 **"));
231/// ```
232#[derive(Clone)]
233pub struct BracketTable {
234    /// Label template (see the type docs); `None` = `label` column or `"p = {p}"`.
235    pub label: Option<String>,
236    /// Column naming the left group.
237    pub group1: String,
238    /// Column naming the right group.
239    pub group2: String,
240    /// Explicit p-value column; `None` = `p_adj`, falling back to `p_value`.
241    pub p_column: Option<String>,
242    /// Column with explicit bracket heights.
243    pub y_position: String,
244    /// Gap between auto-stacked brackets as a fraction of the data's y range.
245    pub step_increase: f64,
246    /// Drop non-significant rows (p above the last-but-one cutpoint, i.e.
247    /// labelled `ns`).
248    pub hide_ns: bool,
249    /// Significance-star cutpoints `(upper bound, symbol)`, ascending; a p
250    /// at or below a bound gets that symbol. Default (ggpubr):
251    /// `≤1e-4 ****`, `≤0.001 ***`, `≤0.01 **`, `≤0.05 *`, else `ns`.
252    pub cutpoints: Vec<(f64, String)>,
253    /// Bracket appearance.
254    pub geom: GeomBracketStyle,
255}
256
257/// Appearance of table-driven brackets (a cloneable mirror of
258/// [`GeomBracket`]'s fields).
259#[derive(Clone, Copy, Debug)]
260pub struct GeomBracketStyle {
261    pub color: (u8, u8, u8),
262    pub line_width: f64,
263    pub tip_length: f64,
264    pub label_size: f64,
265}
266
267impl Default for GeomBracketStyle {
268    fn default() -> Self {
269        let g = GeomBracket::default();
270        GeomBracketStyle {
271            color: g.color,
272            line_width: g.line_width,
273            tip_length: g.tip_length,
274            label_size: g.label_size,
275        }
276    }
277}
278
279impl From<GeomBracketStyle> for GeomBracket {
280    fn from(s: GeomBracketStyle) -> Self {
281        GeomBracket {
282            color: s.color,
283            line_width: s.line_width,
284            tip_length: s.tip_length,
285            label_size: s.label_size,
286        }
287    }
288}
289
290impl Default for BracketTable {
291    fn default() -> Self {
292        BracketTable {
293            label: None,
294            group1: "group1".into(),
295            group2: "group2".into(),
296            p_column: None,
297            y_position: "y_position".into(),
298            step_increase: 0.12,
299            hide_ns: false,
300            cutpoints: vec![
301                (1e-4, "****".into()),
302                (1e-3, "***".into()),
303                (1e-2, "**".into()),
304                (0.05, "*".into()),
305                (f64::INFINITY, "ns".into()),
306            ],
307            geom: GeomBracketStyle::default(),
308        }
309    }
310}
311
312impl BracketTable {
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    /// Label template, e.g. `"p = {p_adj}"` or `"{p.signif}"`.
318    pub fn label(mut self, template: &str) -> Self {
319        self.label = Some(template.to_string());
320        self
321    }
322
323    /// Label each bracket with significance stars only (`"{p.signif}"`).
324    pub fn stars(self) -> Self {
325        self.label("{p.signif}")
326    }
327
328    /// Read the p-value from this column instead of `p_adj`/`p_value`.
329    pub fn p_column(mut self, col: &str) -> Self {
330        self.p_column = Some(col.to_string());
331        self
332    }
333
334    /// Use other column names for the compared groups.
335    pub fn groups(mut self, group1: &str, group2: &str) -> Self {
336        self.group1 = group1.to_string();
337        self.group2 = group2.to_string();
338        self
339    }
340
341    /// Read explicit heights from this column (default `y_position`).
342    pub fn y_position(mut self, col: &str) -> Self {
343        self.y_position = col.to_string();
344        self
345    }
346
347    /// Spacing of auto-stacked brackets (fraction of the y range; default 0.12).
348    pub fn step_increase(mut self, step: f64) -> Self {
349        self.step_increase = step;
350        self
351    }
352
353    /// Hide non-significant comparisons.
354    pub fn hide_ns(mut self, hide: bool) -> Self {
355        self.hide_ns = hide;
356        self
357    }
358
359    /// Custom significance-star cutpoints `(upper bound, symbol)`, ascending.
360    pub fn cutpoints(mut self, cutpoints: &[(f64, &str)]) -> Self {
361        self.cutpoints = cutpoints.iter().map(|(c, s)| (*c, s.to_string())).collect();
362        self
363    }
364
365    /// Bracket appearance.
366    pub fn style(mut self, style: GeomBracketStyle) -> Self {
367        self.geom = style;
368        self
369    }
370
371    /// The star symbol for `p` (empty for a missing / non-finite p).
372    pub fn signif(&self, p: f64) -> String {
373        if !p.is_finite() {
374            return String::new();
375        }
376        self.cutpoints
377            .iter()
378            .find(|(c, _)| p <= *c)
379            .map(|(_, s)| s.clone())
380            .unwrap_or_default()
381    }
382
383    /// Whether `p` lands in the last ("not significant") cutpoint class.
384    fn is_ns(&self, p: f64) -> bool {
385        match self.cutpoints.len() {
386            0 => false,
387            n => {
388                let below = if n >= 2 {
389                    self.cutpoints[n - 2].0
390                } else {
391                    f64::NEG_INFINITY
392                };
393                p.is_finite() && p > below
394            }
395        }
396    }
397
398    /// Resolve the table into bracket layer data (`xmin`, `xmax`, `y`,
399    /// `label` + hover columns). `x_levels` are the plot's x categories (empty
400    /// = unknown, no filtering); `y_range` the finite data range (for
401    /// auto-stacking). Problems are appended to `warnings`.
402    pub(crate) fn resolve(
403        &self,
404        table: &DataFrame,
405        x_levels: &[String],
406        y_range: Option<(f64, f64)>,
407        warnings: &mut Vec<String>,
408    ) -> Option<DataFrame> {
409        const WHO: &str = "geom_bracket";
410        if let Err(e) = table.validate() {
411            warnings.push(format!(
412                "{WHO}: test table rejected ({e}); no brackets drawn"
413            ));
414            return None;
415        }
416        let (Some(g1), Some(g2)) = (table.column(&self.group1), table.column(&self.group2)) else {
417            warnings.push(format!(
418                "{WHO}: test table needs '{}' and '{}' columns; no brackets drawn",
419                self.group1, self.group2
420            ));
421            return None;
422        };
423        let p_of = |i: usize| -> Option<f64> {
424            let get = |c: &str| {
425                table
426                    .column(c)
427                    .and_then(|col| col.get(i))
428                    .and_then(|v| v.as_f64())
429                    .filter(|p| !p.is_nan())
430            };
431            match &self.p_column {
432                Some(c) => get(c),
433                None => get("p_adj").or_else(|| get("p_value")),
434            }
435        };
436
437        let mut rows: Vec<(usize, f64)> = Vec::new(); // (row, p or NaN)
438        let (mut missing, mut unknown, mut ns) = (0usize, Vec::new(), 0usize);
439        for i in 0..table.nrows() {
440            let (a, b) = (&g1[i], &g2[i]);
441            if a.is_na() || b.is_na() {
442                missing += 1;
443                continue;
444            }
445            if !x_levels.is_empty() {
446                let bad: Vec<String> = [a, b]
447                    .iter()
448                    .map(|v| v.to_group_key())
449                    .filter(|k| !x_levels.contains(k))
450                    .collect();
451                if !bad.is_empty() {
452                    unknown.extend(bad);
453                    continue;
454                }
455            }
456            let p = p_of(i).unwrap_or(f64::NAN);
457            if self.hide_ns && self.is_ns(p) {
458                ns += 1;
459                continue;
460            }
461            rows.push((i, p));
462        }
463        if missing > 0 {
464            warnings.push(format!(
465                "{WHO}: removed {missing} row{} with a missing group",
466                if missing == 1 { "" } else { "s" }
467            ));
468        }
469        if !unknown.is_empty() {
470            unknown.dedup();
471            warnings.push(format!(
472                "{WHO}: removed {} row{} comparing groups not on the x axis ({})",
473                unknown.len(),
474                if unknown.len() == 1 { "" } else { "s" },
475                unknown.join(", ")
476            ));
477        }
478        let _ = ns; // hidden on request: not a warning
479        if rows.is_empty() {
480            return None;
481        }
482
483        // Heights: explicit finite y_position, else auto-stacked.
484        let given = |i: usize| {
485            table
486                .column(&self.y_position)
487                .and_then(|c| c.get(i))
488                .and_then(|v| v.as_f64())
489                .filter(|y| y.is_finite())
490        };
491        let max_given = rows
492            .iter()
493            .filter_map(|(i, _)| given(*i))
494            .fold(f64::NEG_INFINITY, f64::max);
495        let (base, step) = match y_range {
496            Some((lo, hi)) => {
497                let span = hi - lo;
498                let unit = if span > 0.0 {
499                    span
500                } else if hi != 0.0 {
501                    hi.abs()
502                } else {
503                    1.0
504                };
505                let step = if self.step_increase.is_finite() && self.step_increase > 0.0 {
506                    unit * self.step_increase
507                } else {
508                    unit * 0.12
509                };
510                (hi.max(max_given), step)
511            }
512            None if max_given.is_finite() => (max_given, max_given.abs().max(1.0) * 0.12),
513            None => (0.0, 1.0),
514        };
515        let needs_auto = rows.iter().any(|(i, _)| given(*i).is_none());
516        if needs_auto && y_range.is_none() && !max_given.is_finite() {
517            warnings.push(format!(
518                "{WHO}: no y_position and no y data to stack above; brackets placed from 0"
519            ));
520        }
521
522        let mut out = DataFrame::new();
523        let (mut xmin, mut xmax, mut y, mut label, mut pv, mut series) =
524            (vec![], vec![], vec![], vec![], vec![], vec![]);
525        let mut auto_k = 0usize;
526        for (i, p) in rows {
527            xmin.push(g1[i].clone());
528            xmax.push(g2[i].clone());
529            let yy = given(i).unwrap_or_else(|| {
530                auto_k += 1;
531                base + step * auto_k as f64
532            });
533            y.push(Value::Float(yy));
534            label.push(Value::Str(self.label_for(table, i, p)));
535            pv.push(if p.is_finite() {
536                Value::Float(p)
537            } else {
538                Value::Na
539            });
540            series.push(
541                table
542                    .column("test_id")
543                    .and_then(|c| c.get(i))
544                    .cloned()
545                    .unwrap_or(Value::Na),
546            );
547        }
548        out.add_column("xmin".into(), xmin);
549        out.add_column("xmax".into(), xmax);
550        out.add_column("y".into(), y);
551        out.add_column("label".into(), label);
552        out.add_column(BRACKET_P_COL.into(), pv);
553        out.add_column(BRACKET_SERIES_COL.into(), series);
554        Some(out)
555    }
556
557    fn label_for(&self, table: &DataFrame, i: usize, p: f64) -> String {
558        let template = match &self.label {
559            Some(t) => t.clone(),
560            None => match table.column("label").and_then(|c| c.get(i)) {
561                Some(v) if !v.is_na() => return v.to_group_key(),
562                _ => "p = {p}".to_string(),
563            },
564        };
565        let mut out = String::with_capacity(template.len());
566        let mut rest = template.as_str();
567        while let Some(open) = rest.find('{') {
568            out.push_str(&rest[..open]);
569            let after = &rest[open + 1..];
570            let Some(close) = after.find('}') else {
571                out.push_str(&rest[open..]);
572                rest = "";
573                break;
574            };
575            let key = &after[..close];
576            let fmt_p = |p: f64| {
577                if p.is_finite() {
578                    format_p_number(p)
579                } else {
580                    "NA".to_string()
581                }
582            };
583            let sub = match key {
584                "p" => Some(fmt_p(p)),
585                "p.signif" | "stars" => Some(if p.is_finite() {
586                    self.signif(p)
587                } else {
588                    "NA".to_string()
589                }),
590                "p.format" => Some(if p.is_finite() {
591                    format!("p = {}", format_p_number(p))
592                } else {
593                    "p = NA".to_string()
594                }),
595                col => table.column(col).and_then(|c| c.get(i)).map(|v| match v {
596                    Value::Na => "NA".to_string(),
597                    v if col.starts_with('p') && v.as_f64().is_some() && !v.is_datetime() => {
598                        fmt_p(v.as_f64().unwrap_or(f64::NAN))
599                    }
600                    v => crate::format::format_value(v),
601                }),
602            };
603            match sub {
604                Some(s) => out.push_str(&s),
605                None => {
606                    out.push('{');
607                    out.push_str(key);
608                    out.push('}');
609                }
610            }
611            rest = &after[close + 1..];
612        }
613        out.push_str(rest);
614        out
615    }
616}
617
618/// A p-value for a label: 4 significant digits (trailing zeros dropped),
619/// scientific below 1e-4, `< 2.2e-16` at R's floor.
620pub fn format_p_number(p: f64) -> String {
621    if !p.is_finite() {
622        return "NA".to_string();
623    }
624    if p < 2.2e-16 {
625        return "< 2.2e-16".to_string();
626    }
627    if p < 1e-4 {
628        return format!("{p:.2e}");
629    }
630    let digits = (4 - 1 - p.abs().log10().floor() as i32).clamp(0, 12) as usize;
631    let s = format!("{p:.digits$}");
632    if s.contains('.') {
633        s.trim_end_matches('0').trim_end_matches('.').to_string()
634    } else {
635        s
636    }
637}
638
639#[cfg(test)]
640mod table_tests {
641    use super::*;
642
643    fn s(v: &str) -> Value {
644        Value::Str(v.into())
645    }
646
647    fn table() -> DataFrame {
648        let mut t = DataFrame::new();
649        t.add_column("group1".into(), vec![s("a"), s("a"), s("b"), Value::Na]);
650        t.add_column("group2".into(), vec![s("b"), s("c"), s("c"), s("c")]);
651        t.add_column(
652            "p_value".into(),
653            vec![
654                Value::Float(0.0004),
655                Value::Float(0.03),
656                Value::Float(0.4),
657                Value::Float(0.01),
658            ],
659        );
660        t.add_column(
661            "p_adj".into(),
662            vec![
663                Value::Float(0.0012),
664                Value::Na,
665                Value::Float(0.9),
666                Value::Na,
667            ],
668        );
669        t
670    }
671
672    fn levels() -> Vec<String> {
673        vec!["a".into(), "b".into(), "c".into()]
674    }
675
676    #[test]
677    fn p_adj_falls_back_to_p_value_and_stacks() {
678        let mut w = Vec::new();
679        let out = BracketTable::new()
680            .label("{p} {stars}")
681            .resolve(&table(), &levels(), Some((0.0, 10.0)), &mut w)
682            .unwrap();
683        let labels: Vec<String> = out
684            .column("label")
685            .unwrap()
686            .iter()
687            .map(|v| v.to_group_key())
688            .collect();
689        assert_eq!(labels, vec!["0.0012 **", "0.03 *", "0.9 ns"]);
690        let ys: Vec<f64> = out
691            .column("y")
692            .unwrap()
693            .iter()
694            .map(|v| v.as_f64().unwrap())
695            .collect();
696        assert!(
697            (ys[0] - 11.2).abs() < 1e-9
698                && (ys[1] - 12.4).abs() < 1e-9
699                && (ys[2] - 13.6).abs() < 1e-9
700        );
701        assert!(w.iter().any(|m| m.contains("missing group")), "{w:?}");
702    }
703
704    #[test]
705    fn hide_ns_and_unknown_groups() {
706        let mut t = table();
707        t.add_column("y_position".into(), vec![Value::Float(20.0); 4]);
708        let mut w = Vec::new();
709        let out = BracketTable::new()
710            .hide_ns(true)
711            .resolve(&t, &["a".into(), "b".into()], Some((0.0, 1.0)), &mut w)
712            .unwrap();
713        // a–c and b–c compare a level not on the axis; a–b is kept.
714        assert_eq!(out.nrows(), 1);
715        assert_eq!(out.column("y").unwrap()[0].as_f64(), Some(20.0));
716        assert!(w.iter().any(|m| m.contains("not on the x axis")), "{w:?}");
717    }
718
719    #[test]
720    fn label_column_and_unknown_keys() {
721        let mut t = table();
722        t.add_column("label".into(), vec![s("custom"), Value::Na, s("x"), s("y")]);
723        let mut w = Vec::new();
724        let out = BracketTable::new().resolve(&t, &[], None, &mut w).unwrap();
725        let labels: Vec<String> = out
726            .column("label")
727            .unwrap()
728            .iter()
729            .map(|v| v.to_group_key())
730            .collect();
731        assert_eq!(labels[0], "custom");
732        assert_eq!(labels[1], "p = 0.03");
733        let mut w = Vec::new();
734        let out = BracketTable::new()
735            .label("{nope} {group1}")
736            .resolve(&t, &[], None, &mut w)
737            .unwrap();
738        assert_eq!(out.column("label").unwrap()[0].to_group_key(), "{nope} a");
739    }
740
741    #[test]
742    fn formats_p_numbers() {
743        assert_eq!(format_p_number(0.03), "0.03");
744        assert_eq!(format_p_number(0.012345), "0.01235");
745        assert_eq!(format_p_number(0.5), "0.5");
746        assert_eq!(format_p_number(1.0), "1");
747        assert_eq!(format_p_number(3e-5), "3.00e-5");
748        assert_eq!(format_p_number(1e-20), "< 2.2e-16");
749        assert_eq!(format_p_number(f64::NAN), "NA");
750    }
751}