ggplot_rs/aes/mod.rs
1pub mod expr;
2pub mod mapping;
3
4pub use mapping::{apply_after_stat, resolve_mappings};
5
6/// All supported aesthetic channels.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub enum Aesthetic {
9 X,
10 Y,
11 Color,
12 Fill,
13 Size,
14 Shape,
15 Alpha,
16 Linetype,
17 Group,
18 Ymin,
19 Ymax,
20 Xmin,
21 Xmax,
22 Label,
23 Weight,
24 Xend,
25 Yend,
26 Angle,
27 Radius,
28 /// Stroke width for segment/curve/path/line (R's `linewidth`).
29 Linewidth,
30 /// A custom `<title>` hover tooltip, replacing the geom's own. Routed
31 /// through [`crate::aes::TOOLTIP_INTERNAL_NAME`] in `resolve_mappings`,
32 /// the same way `DataAttr` is, so an unrelated raw column literally
33 /// named `"tooltip"` can never be mistaken for a mapped one.
34 Tooltip,
35 /// A custom `data-<name>` attribute on the mark. The `String` is the
36 /// full attribute name (`"data-<name>"`). [`Aes::data_attr`] validates
37 /// `name` against `[a-z0-9][a-z0-9-]*` (excluding reserved names) before
38 /// constructing this; a `DataAttr` built directly — bypassing that
39 /// builder — is re-validated in `resolve_mappings`
40 /// (`aes::validated_data_attr_internal_name`), the single authoritative
41 /// gate regardless of how the mapping was created.
42 DataAttr(String),
43 /// Financial OHLC aesthetics (`geom_candlestick` / `geom_ohlc`); they
44 /// share the y position scale.
45 Open,
46 High,
47 Low,
48 Close,
49 /// Reference-line aesthetics (`geom_hline` / `geom_vline` /
50 /// `geom_abline`). `xintercept`/`yintercept` share (and train) the x / y
51 /// position scale, as in ggplot2; `slope`/`intercept` get no scale.
52 Xintercept,
53 Yintercept,
54 Slope,
55 Intercept,
56 /// Boxplot `stat="identity"` aesthetics (B3-5; ggplot2 extension, D4):
57 /// the box's lower/middle/upper hinges. Live on the y scale (D-style,
58 /// like `ymin`/`ymax`) — [`crate::scale::ScaleSet::ensure_scale`] gives
59 /// them no scale of their own.
60 Lower,
61 Middle,
62 Upper,
63 /// A comma-separated list of outlier values, optionally bracketed
64 /// (`"7, 9"` or `"[7, 9]"`) — ggplot2 extension, D4. Unparsable parts
65 /// are skipped with one warning. Lives on the y scale, like `Lower`/
66 /// `Middle`/`Upper`.
67 Outliers,
68}
69
70impl Aesthetic {
71 /// The canonical column name used in the working DataFrame after aes evaluation.
72 pub fn col_name(&self) -> &str {
73 match self {
74 Aesthetic::X => "x",
75 Aesthetic::Y => "y",
76 Aesthetic::Color => "color",
77 Aesthetic::Fill => "fill",
78 Aesthetic::Size => "size",
79 Aesthetic::Shape => "shape",
80 Aesthetic::Alpha => "alpha",
81 Aesthetic::Linetype => "linetype",
82 Aesthetic::Group => "group",
83 Aesthetic::Ymin => "ymin",
84 Aesthetic::Ymax => "ymax",
85 Aesthetic::Xmin => "xmin",
86 Aesthetic::Xmax => "xmax",
87 Aesthetic::Label => "label",
88 Aesthetic::Weight => "weight",
89 Aesthetic::Xend => "xend",
90 Aesthetic::Yend => "yend",
91 Aesthetic::Angle => "angle",
92 Aesthetic::Radius => "radius",
93 Aesthetic::Linewidth => "linewidth",
94 Aesthetic::Tooltip => "tooltip",
95 Aesthetic::DataAttr(name) => name,
96 Aesthetic::Open => "open",
97 Aesthetic::High => "high",
98 Aesthetic::Low => "low",
99 Aesthetic::Close => "close",
100 Aesthetic::Xintercept => "xintercept",
101 Aesthetic::Yintercept => "yintercept",
102 Aesthetic::Slope => "slope",
103 Aesthetic::Intercept => "intercept",
104 Aesthetic::Lower => "lower",
105 Aesthetic::Middle => "middle",
106 Aesthetic::Upper => "upper",
107 Aesthetic::Outliers => "outliers",
108 }
109 }
110}
111
112/// When an aesthetic mapping should be resolved.
113#[derive(Clone, Debug, PartialEq)]
114pub enum MappingStage {
115 /// Resolve before stat computation (default — maps from raw data columns).
116 BeforeStat,
117 /// Resolve after stat computation (maps from stat-computed columns like `density`, `count`).
118 AfterStat,
119}
120
121/// Maps a source column to an aesthetic channel.
122#[derive(Clone, Debug)]
123pub struct AesMapping {
124 pub column: String,
125 pub aesthetic: Aesthetic,
126 pub stage: MappingStage,
127}
128
129/// A post-scale aesthetic derivation (R's `after_scale`): the `target` color
130/// aesthetic is set to the `source` aesthetic's *mapped* color, adjusted in
131/// lightness (`+` toward white, `-` toward black, in `-1.0..=1.0`).
132#[derive(Clone, Debug)]
133pub struct AfterScaleSpec {
134 pub target: Aesthetic,
135 pub source: Aesthetic,
136 pub lightness: f64,
137}
138
139/// Builder for aesthetic mappings.
140#[derive(Clone, Debug, Default)]
141pub struct Aes {
142 pub mappings: Vec<AesMapping>,
143 /// Post-scale color derivations (`after_scale`).
144 pub after_scale: Vec<AfterScaleSpec>,
145 /// Non-fatal build warnings (e.g. an invalid [`data_attr`](Self::data_attr)
146 /// name), merged into [`GGPlot`](crate::plot::GGPlot)'s warnings by
147 /// `aes()`/`layer_aes()`.
148 pub(crate) warnings: Vec<String>,
149}
150
151impl Aes {
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
157 self.mappings.push(AesMapping {
158 column: col.to_string(),
159 aesthetic,
160 stage: MappingStage::BeforeStat,
161 });
162 self
163 }
164
165 fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
166 self.mappings.push(AesMapping {
167 column: col.to_string(),
168 aesthetic,
169 stage: MappingStage::AfterStat,
170 });
171 self
172 }
173
174 // ─── after_scale() — post-scale color derivation ────────────
175
176 /// Set `fill` to the mapped `color` with adjusted lightness (R's
177 /// `aes(fill = after_scale(...))`). `lightness` in `-1.0..=1.0`: positive
178 /// lightens toward white, negative darkens toward black.
179 pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
180 self.after_scale.push(AfterScaleSpec {
181 target: Aesthetic::Fill,
182 source: Aesthetic::Color,
183 lightness,
184 });
185 self
186 }
187
188 /// Set `color` to the mapped `fill` with adjusted lightness — useful for a
189 /// darker border around a filled shape (`color = after_scale(darken(fill))`).
190 pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
191 self.after_scale.push(AfterScaleSpec {
192 target: Aesthetic::Color,
193 source: Aesthetic::Fill,
194 lightness,
195 });
196 self
197 }
198
199 /// `stage(start, after_stat)`: map `aesthetic` from `start` before the stat
200 /// and re-map it from `after_stat` afterwards (R's `stage()`).
201 pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
202 self.mappings.push(AesMapping {
203 column: start.to_string(),
204 aesthetic: aesthetic.clone(),
205 stage: MappingStage::BeforeStat,
206 });
207 self.mappings.push(AesMapping {
208 column: after_stat.to_string(),
209 aesthetic,
210 stage: MappingStage::AfterStat,
211 });
212 self
213 }
214
215 pub fn x(self, col: &str) -> Self {
216 self.push(col, Aesthetic::X)
217 }
218 pub fn y(self, col: &str) -> Self {
219 self.push(col, Aesthetic::Y)
220 }
221 pub fn color(self, col: &str) -> Self {
222 self.push(col, Aesthetic::Color)
223 }
224 pub fn fill(self, col: &str) -> Self {
225 self.push(col, Aesthetic::Fill)
226 }
227 pub fn size(self, col: &str) -> Self {
228 self.push(col, Aesthetic::Size)
229 }
230 pub fn shape(self, col: &str) -> Self {
231 self.push(col, Aesthetic::Shape)
232 }
233 pub fn alpha(self, col: &str) -> Self {
234 self.push(col, Aesthetic::Alpha)
235 }
236 pub fn group(self, col: &str) -> Self {
237 self.push(col, Aesthetic::Group)
238 }
239 pub fn ymin(self, col: &str) -> Self {
240 self.push(col, Aesthetic::Ymin)
241 }
242 pub fn ymax(self, col: &str) -> Self {
243 self.push(col, Aesthetic::Ymax)
244 }
245 pub fn label(self, col: &str) -> Self {
246 self.push(col, Aesthetic::Label)
247 }
248 pub fn weight(self, col: &str) -> Self {
249 self.push(col, Aesthetic::Weight)
250 }
251 pub fn xend(self, col: &str) -> Self {
252 self.push(col, Aesthetic::Xend)
253 }
254 pub fn yend(self, col: &str) -> Self {
255 self.push(col, Aesthetic::Yend)
256 }
257 pub fn xmin(self, col: &str) -> Self {
258 self.push(col, Aesthetic::Xmin)
259 }
260 pub fn xmax(self, col: &str) -> Self {
261 self.push(col, Aesthetic::Xmax)
262 }
263 pub fn angle(self, col: &str) -> Self {
264 self.push(col, Aesthetic::Angle)
265 }
266 pub fn radius(self, col: &str) -> Self {
267 self.push(col, Aesthetic::Radius)
268 }
269 pub fn linetype(self, col: &str) -> Self {
270 self.push(col, Aesthetic::Linetype)
271 }
272 /// Stroke width for segment/curve/path/line (R's `linewidth`).
273 pub fn linewidth(self, col: &str) -> Self {
274 self.push(col, Aesthetic::Linewidth)
275 }
276 /// Boxplot `stat="identity"` lower hinge (B3-5).
277 pub fn lower(self, col: &str) -> Self {
278 self.push(col, Aesthetic::Lower)
279 }
280 /// Boxplot `stat="identity"` middle (median) line (B3-5).
281 pub fn middle(self, col: &str) -> Self {
282 self.push(col, Aesthetic::Middle)
283 }
284 /// Boxplot `stat="identity"` upper hinge (B3-5).
285 pub fn upper(self, col: &str) -> Self {
286 self.push(col, Aesthetic::Upper)
287 }
288 /// Boxplot `stat="identity"` outlier values, as a comma list with
289 /// optional brackets (`"7, 9"` or `"[7, 9]"`) (B3-5).
290 pub fn outliers(self, col: &str) -> Self {
291 self.push(col, Aesthetic::Outliers)
292 }
293
294 /// A custom `<title>` hover tooltip from `col`, replacing the geom's
295 /// own. `ensure_scale` skips it (no scale, no legend): computing stats
296 /// drop the column (identity-stat layers keep it), as `linetype`/`alpha`
297 /// do.
298 pub fn tooltip(self, col: &str) -> Self {
299 self.push(col, Aesthetic::Tooltip)
300 }
301
302 /// A custom `data-<name>` attribute on the mark, from `col`. `name` must
303 /// match `[a-z0-9][a-z0-9-]*` and not be a name ggplot-rs itself writes
304 /// via another mechanism (currently just `"part"` — see
305 /// `DrawBackend::set_mark_part`); an invalid or reserved name is skipped
306 /// with a build warning (surfaced via
307 /// [`GGPlot::aes`](crate::plot::GGPlot::aes) / `layer_aes`). A user attr
308 /// replaces a built-in of the same name (`data-x`, `data-series`,
309 /// `data-value`) — never duplicated.
310 pub fn data_attr(mut self, name: &str, col: &str) -> Self {
311 if !is_valid_data_attr_name(name) {
312 let reason = if RESERVED_DATA_ATTR_NAMES.contains(&name) {
313 "reserved (written by ggplot-rs itself)"
314 } else {
315 "invalid (must match [a-z0-9][a-z0-9-]*)"
316 };
317 self.warnings
318 .push(format!("aes: data_attr name {name:?} is {reason}; skipped"));
319 return self;
320 }
321 self.push(col, Aesthetic::DataAttr(format!("data-{name}")))
322 }
323 /// Opening price (`geom_candlestick` / `geom_ohlc`).
324 pub fn open(self, col: &str) -> Self {
325 self.push(col, Aesthetic::Open)
326 }
327 /// Period high (`geom_candlestick` / `geom_ohlc`).
328 pub fn high(self, col: &str) -> Self {
329 self.push(col, Aesthetic::High)
330 }
331 /// Period low (`geom_candlestick` / `geom_ohlc`).
332 pub fn low(self, col: &str) -> Self {
333 self.push(col, Aesthetic::Low)
334 }
335 /// Closing price (`geom_candlestick` / `geom_ohlc`).
336 pub fn close(self, col: &str) -> Self {
337 self.push(col, Aesthetic::Close)
338 }
339 /// Data-mapped `geom_vline` position (one line per row).
340 pub fn xintercept(self, col: &str) -> Self {
341 self.push(col, Aesthetic::Xintercept)
342 }
343 /// Data-mapped `geom_hline` position (one line per row).
344 pub fn yintercept(self, col: &str) -> Self {
345 self.push(col, Aesthetic::Yintercept)
346 }
347 /// Data-mapped `geom_abline` slope (one line per row).
348 pub fn slope(self, col: &str) -> Self {
349 self.push(col, Aesthetic::Slope)
350 }
351 /// Data-mapped `geom_abline` intercept (one line per row).
352 pub fn intercept(self, col: &str) -> Self {
353 self.push(col, Aesthetic::Intercept)
354 }
355
356 // ─── after_stat() mappings ──────────────────────────────────
357 // Map stat-computed columns (e.g., `density`, `count`, `ncount`, `ndensity`)
358 // to an aesthetic. These are resolved after the stat step in the build pipeline.
359
360 /// Map a stat-computed column to the y aesthetic (e.g., `after_stat_y("density")`).
361 pub fn after_stat_y(self, col: &str) -> Self {
362 self.push_after_stat(col, Aesthetic::Y)
363 }
364
365 /// Map a stat-computed column to the x aesthetic.
366 pub fn after_stat_x(self, col: &str) -> Self {
367 self.push_after_stat(col, Aesthetic::X)
368 }
369
370 /// Map a stat-computed column to the fill aesthetic.
371 pub fn after_stat_fill(self, col: &str) -> Self {
372 self.push_after_stat(col, Aesthetic::Fill)
373 }
374
375 /// Map a stat-computed column to the color aesthetic.
376 pub fn after_stat_color(self, col: &str) -> Self {
377 self.push_after_stat(col, Aesthetic::Color)
378 }
379
380 /// Map a stat-computed column to the size aesthetic.
381 pub fn after_stat_size(self, col: &str) -> Self {
382 self.push_after_stat(col, Aesthetic::Size)
383 }
384
385 /// Map a stat-computed column to the alpha aesthetic.
386 pub fn after_stat_alpha(self, col: &str) -> Self {
387 self.push_after_stat(col, Aesthetic::Alpha)
388 }
389
390 /// Map a stat-computed column to the linewidth aesthetic.
391 pub fn after_stat_linewidth(self, col: &str) -> Self {
392 self.push_after_stat(col, Aesthetic::Linewidth)
393 }
394
395 /// Get the column mapped to a specific aesthetic.
396 pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
397 self.mappings
398 .iter()
399 .find(|m| m.aesthetic == *aes)
400 .map(|m| m.column.as_str())
401 }
402
403 /// Merge another Aes into this one. The other's mappings override on conflict.
404 pub fn merge(&self, other: &Aes) -> Aes {
405 let mut result = self.clone();
406 for m in &other.mappings {
407 // Remove existing mapping for same aesthetic
408 result
409 .mappings
410 .retain(|existing| existing.aesthetic != m.aesthetic);
411 result.mappings.push(m.clone());
412 }
413 result.warnings.extend(other.warnings.iter().cloned());
414 result
415 }
416}
417
418/// Names ggplot-rs itself writes via a mechanism other than `data_attr`'s
419/// `set_mark_attrs` dedup — reserved so a user mapping can never collide and
420/// produce a duplicate attribute. `"part"` is `DrawBackend::set_mark_part`
421/// (arrowheads: `data-part="arrow"`), written directly in `push_mark`, not
422/// through the built-ins list `set_mark_attrs` dedups against. `"geom"` is
423/// `SvgBackend::mark_class`'s `data-geom="<layer geom>"`, written the same
424/// way, always last, alongside the `gg-mark` class. `"fvalue"`/`"fx"` are
425/// `DrawBackend::set_mark_fvalue`/`set_mark_fx` (B2-4), written the same way.
426const RESERVED_DATA_ATTR_NAMES: &[&str] = &["part", "geom", "fvalue", "fx"];
427
428/// `[a-z0-9][a-z0-9-]*`, excluding [`RESERVED_DATA_ATTR_NAMES`] — a valid,
429/// non-colliding HTML custom-data-attribute suffix.
430pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
431 if RESERVED_DATA_ATTR_NAMES.contains(&name) {
432 return false;
433 }
434 let mut chars = name.chars();
435 match chars.next() {
436 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
437 _ => return false,
438 }
439 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
440}
441
442/// Internal storage namespace for validated interactive-aesthetic columns
443/// (`data_attr`, `tooltip`) in the built DataFrame. Starts with a NUL byte,
444/// which can't appear in any column name `resolve_mappings` carries over
445/// from a caller's raw source data: that loop explicitly drops a raw column
446/// whose name contains a NUL (or this prefix) rather than carrying it
447/// through unchanged — see its doc. So in practice no caller-supplied
448/// column collides with a name under this prefix, but the real guarantee is
449/// that drop, not merely that a legitimate column "wouldn't" be named this
450/// way (`GGPlot::new` accepts any `String`, including one with a NUL byte
451/// in it, deliberately crafted to collide).
452pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
453
454/// Internal storage prefix for validated `data_attr` columns — see
455/// [`INTERNAL_PREFIX`]. `geom::set_row_extras` looks for *only* this
456/// prefix instead of scanning for anything that merely looks like a
457/// `data-*` column, which would also match an unrelated raw source column
458/// a caller never mapped through `Aes::data_attr` (the injection this
459/// guards against: see `validated_data_attr_internal_name`).
460pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
461
462/// Internal storage name for a validated `Aes::tooltip` mapping — see
463/// [`INTERNAL_PREFIX`]. Without this, an unmapped raw column literally
464/// named `"tooltip"` would drive every mark's hover title (the same class
465/// of bug `DATA_ATTR_INTERNAL_PREFIX` fixes for `data_attr`); routing it
466/// through the same internal-prefix scheme closes that for tooltips too.
467pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
468
469/// Internal storage names for `GeomBoxplot::setup_data`'s outlier-extent
470/// columns — see [`INTERNAL_PREFIX`] (review #158 finding 5: these used to
471/// be plain `"ymin_final"`/`"ymax_final"`, trained into the y scale for
472/// *every* layer at build Step 8b regardless of which geom produced them;
473/// `resolve_mappings` carries over any raw source column with a
474/// not-otherwise-mapped canonical name, so a raw column literally named
475/// `ymin_final` — e.g. a SQL alias — on an unrelated geom like `geom_point`
476/// silently widened its y domain. Routing them through the internal-prefix
477/// scheme means only `GeomBoxplot::setup_data` itself can ever produce
478/// these columns; a raw column with this name is already stripped by
479/// `is_forged_internal_name` before it reaches the built data.
480pub(crate) const BOXPLOT_YMIN_FINAL: &str = "\u{0}ggplot-rs:ymin_final";
481pub(crate) const BOXPLOT_YMAX_FINAL: &str = "\u{0}ggplot-rs:ymax_final";
482
483/// Validate a `data_attr` mapping's target (`"data-<name>"`, as stored in
484/// [`Aesthetic::DataAttr`]) and, if valid, return the internal column name
485/// it should be written under. This is the single authoritative gate: it
486/// runs in `resolve_mappings` regardless of *how* the `DataAttr` mapping was
487/// created — the validated `Aes::data_attr` builder, or a `DataAttr`
488/// constructed directly and pushed into the public `Aes.mappings` field,
489/// bypassing the builder's own check.
490pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
491 let suffix = data_attr_target.strip_prefix("data-")?;
492 if is_valid_data_attr_name(suffix) {
493 Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
494 } else {
495 None
496 }
497}
498
499#[cfg(test)]
500mod data_attr_name_tests {
501 use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
502
503 #[test]
504 fn accepts_lowercase_alnum_and_hyphen() {
505 for ok in ["x", "row", "data-ish", "a1", "0a", "value"] {
506 assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
507 }
508 }
509
510 #[test]
511 fn rejects_empty_uppercase_and_other_punctuation() {
512 for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
513 assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
514 }
515 }
516
517 #[test]
518 fn rejects_the_reserved_part_name() {
519 assert!(!is_valid_data_attr_name("part"));
520 assert_eq!(validated_data_attr_internal_name("data-part"), None);
521 }
522
523 #[test]
524 fn rejects_the_reserved_geom_fvalue_and_fx_names() {
525 // B2-0's `data-geom` and B2-4's `data-fvalue`/`data-fx` are written
526 // directly in `push_mark`, the same way `data-part` is — a
527 // `data_attr` mapping under any of these names would duplicate it.
528 for name in ["geom", "fvalue", "fx"] {
529 assert!(!is_valid_data_attr_name(name), "{name} should be reserved");
530 assert_eq!(
531 validated_data_attr_internal_name(&format!("data-{name}")),
532 None
533 );
534 }
535 }
536
537 #[test]
538 fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
539 // Space, `=`, parens: none of these are legal in the suffix.
540 assert_eq!(
541 validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
542 None
543 );
544 }
545
546 #[test]
547 fn valid_target_maps_to_the_internal_prefix_plus_itself() {
548 let internal = validated_data_attr_internal_name("data-orig").unwrap();
549 assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
550 assert!(internal.ends_with("data-orig"));
551 }
552}
553
554#[cfg(test)]
555mod aesthetic_and_aes_tests {
556 use super::{Aes, Aesthetic};
557
558 /// `col_name()` for `Tooltip`/`DataAttr` is never actually used for
559 /// storage (both are special-cased in `aes::mapping`), but it should
560 /// still return a sane, documented fallback for a direct caller.
561 #[test]
562 fn col_name_for_tooltip_and_data_attr() {
563 assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
564 assert_eq!(
565 Aesthetic::DataAttr("data-orig".to_string()).col_name(),
566 "data-orig"
567 );
568 }
569
570 #[test]
571 fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
572 let invalid = Aes::new().data_attr("Not Valid!", "col");
573 assert_eq!(invalid.warnings.len(), 1);
574 assert!(invalid.warnings[0].contains("invalid"));
575 assert!(!invalid.warnings[0].contains("reserved"));
576
577 let reserved = Aes::new().data_attr("part", "col");
578 assert_eq!(reserved.warnings.len(), 1);
579 assert!(reserved.warnings[0].contains("reserved"));
580 }
581
582 #[test]
583 fn after_stat_linewidth_maps_a_stat_computed_column() {
584 let aes = Aes::new().after_stat_linewidth("weight");
585 assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
586 }
587}