1use std::collections::HashSet;
13
14use crate::aes::Aesthetic;
15use crate::data::Value;
16use crate::format::format_value;
17use crate::render::backend::FillPattern;
18
19use super::format::LabelFormatter;
20use super::Scale;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum NaKey {
26 #[default]
29 Auto,
30 Always,
35 Never,
38}
39
40fn signature(values: &[Value]) -> u64 {
44 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
45 let mut mix = |x: u64| {
46 h ^= x;
47 h = h.wrapping_mul(0x0000_0100_0000_01b3);
48 };
49 mix(values.len() as u64);
50 for v in values {
51 match v {
52 Value::Na => mix(1),
53 _ => match v.as_f64() {
54 Some(f) => {
55 mix(2);
56 mix(f.to_bits());
57 }
58 None => mix(3),
59 },
60 }
61 }
62 h
63}
64
65#[derive(Clone, Debug)]
69pub struct ScaleFillBinned {
70 aesthetic: Aesthetic,
71 breaks: Vec<f64>,
73 colors: Vec<(u8, u8, u8)>,
74 counts: Vec<usize>,
76 na_count: usize,
78 seen: HashSet<u64>,
90 na_color: (u8, u8, u8),
94 na_pattern: Option<FillPattern>,
101 na_label: String,
102 na_key: NaKey,
104 count_reserve: Option<usize>,
109 show_counts: bool,
112 label_formatter: Option<EdgeFormatter>,
115 name: String,
116 corrected: bool,
117}
118
119#[derive(Clone)]
121struct EdgeFormatter(LabelFormatter);
122
123impl std::fmt::Debug for EdgeFormatter {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.write_str("LabelFormatter")
126 }
127}
128
129fn sorted_edges(breaks: Vec<f64>) -> (Vec<f64>, bool) {
141 let mut v: Vec<f64> = breaks.into_iter().filter(|f| f.is_finite()).collect();
142 v.sort_by(f64::total_cmp);
143 if v.len() >= 2 {
144 (v, false)
145 } else {
146 (vec![0.0, 1.0], true)
147 }
148}
149
150fn lerp_color(low: (u8, u8, u8), high: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
152 let t = t.clamp(0.0, 1.0);
153 let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
154 (
155 lerp(low.0, high.0),
156 lerp(low.1, high.1),
157 lerp(low.2, high.2),
158 )
159}
160
161fn default_colors(n: usize, low: (u8, u8, u8), high: (u8, u8, u8)) -> Vec<(u8, u8, u8)> {
164 (0..n)
165 .map(|i| {
166 let t = if n <= 1 {
167 0.5
168 } else {
169 i as f64 / (n - 1) as f64
170 };
171 lerp_color(low, high, t)
172 })
173 .collect()
174}
175
176const DEFAULT_LOW: (u8, u8, u8) = (50, 50, 200);
178const DEFAULT_HIGH: (u8, u8, u8) = (200, 50, 50);
179
180impl ScaleFillBinned {
181 pub fn new(aesthetic: Aesthetic, breaks: Vec<f64>, colors: Vec<(u8, u8, u8)>) -> Self {
186 let (breaks, edges_corrected) = sorted_edges(breaks);
187 let n = breaks.len() - 1;
188 let (colors, colors_corrected) = if colors.len() == n {
189 (colors, false)
190 } else {
191 (default_colors(n, DEFAULT_LOW, DEFAULT_HIGH), true)
192 };
193 ScaleFillBinned {
194 aesthetic,
195 breaks,
196 colors,
197 counts: vec![0; n],
198 na_count: 0,
199 seen: HashSet::new(),
200 na_color: (127, 127, 127),
201 na_pattern: None,
202 na_label: "no data".to_string(),
203 na_key: NaKey::Auto,
204 count_reserve: None,
205 show_counts: true,
206 label_formatter: None,
207 name: String::new(),
208 corrected: edges_corrected || colors_corrected,
209 }
210 }
211
212 pub fn two(
215 aesthetic: Aesthetic,
216 breaks: Vec<f64>,
217 low: (u8, u8, u8),
218 high: (u8, u8, u8),
219 ) -> Self {
220 let (breaks, corrected) = sorted_edges(breaks);
221 let n = breaks.len() - 1;
222 ScaleFillBinned {
223 aesthetic,
224 colors: default_colors(n, low, high),
225 counts: vec![0; n],
226 breaks,
227 na_count: 0,
228 seen: HashSet::new(),
229 na_color: (127, 127, 127),
230 na_pattern: None,
231 na_label: "no data".to_string(),
232 na_key: NaKey::Auto,
233 count_reserve: None,
234 show_counts: true,
235 label_formatter: None,
236 name: String::new(),
237 corrected,
238 }
239 }
240
241 pub fn na_color(mut self, color: (u8, u8, u8)) -> Self {
246 self.na_color = color;
247 self
248 }
249
250 pub fn na_pattern(mut self, pattern: FillPattern) -> Self {
257 self.na_pattern = Some(pattern);
258 self
259 }
260
261 pub fn na_label(mut self, label: &str) -> Self {
263 self.na_label = label.to_string();
264 self
265 }
266
267 pub fn na_key(mut self, show: NaKey) -> Self {
276 self.na_key = show;
277 self
278 }
279
280 pub fn count_reserve(mut self, max_count: usize) -> Self {
291 self.count_reserve = Some(max_count);
292 self
293 }
294
295 pub fn show_counts(mut self, on: bool) -> Self {
300 self.show_counts = on;
301 self
302 }
303
304 pub fn with_label_formatter<F>(mut self, f: F) -> Self
309 where
310 F: Fn(f64) -> String + Send + Sync + 'static,
311 {
312 self.label_formatter = Some(EdgeFormatter(std::sync::Arc::new(f)));
313 self
314 }
315
316 fn edge(&self, v: f64) -> String {
317 match &self.label_formatter {
318 Some(f) => (f.0)(v),
319 None => format_value(&Value::Float(v)),
320 }
321 }
322
323 fn pad_trailing_count(label: &str, min_digits: usize) -> String {
331 if label.ends_with(')') {
332 if let Some(open) = label.rfind('(') {
333 let inner = &label[open + 1..label.len() - 1];
334 if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
335 let digits = min_digits.max(inner.len());
336 return format!("{}({inner:0>digits$})", &label[..open]);
337 }
338 }
339 }
340 label.to_string()
341 }
342
343 fn bin_index(&self, v: f64) -> usize {
359 let n = self.colors.len();
360 let v = v.clamp(self.breaks[0], self.breaks[n]);
361 for i in 0..n {
362 let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
363 let hit = if i == n - 1 {
364 v >= lo && v <= hi } else if lo == hi {
366 v == lo } else {
368 v >= lo && v < hi };
370 if hit {
371 return i;
372 }
373 }
374 n - 1
375 }
376
377 fn class_label(&self, i: usize) -> String {
382 let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
383 let range = if lo == hi {
384 self.edge(lo)
385 } else {
386 format!("{}–{}", self.edge(lo), self.edge(hi))
387 };
388 if self.show_counts {
389 format!("{range} ({})", self.counts[i])
390 } else {
391 range
392 }
393 }
394
395 fn na_label_with_count(&self) -> String {
397 if !self.show_counts {
398 return self.na_label.clone();
399 }
400 format!("{} ({})", self.na_label, self.na_count)
401 }
402
403 fn na_key_shown(&self) -> bool {
405 match self.na_key {
406 NaKey::Auto => self.na_count > 0,
407 NaKey::Always => true,
408 NaKey::Never => false,
409 }
410 }
411}
412
413impl Scale for ScaleFillBinned {
414 fn aesthetic(&self) -> Aesthetic {
415 self.aesthetic.clone()
416 }
417
418 fn train(&mut self, values: &[Value]) {
419 if !self.seen.insert(signature(values)) {
423 return;
424 }
425 for v in values {
426 match v {
427 Value::Na => self.na_count += 1,
428 _ => {
429 if let Some(f) = v.as_f64() {
430 if f.is_finite() {
431 let i = self.bin_index(f);
432 self.counts[i] += 1;
433 }
434 }
435 }
436 }
437 }
438 }
439
440 fn map(&self, _value: &Value) -> f64 {
443 0.0
444 }
445
446 fn breaks(&self) -> Vec<(f64, String)> {
447 let n = self.colors.len();
448 let (lo0, hi_n) = (self.breaks[0], self.breaks[n]);
449 let span = (hi_n - lo0).max(f64::EPSILON);
450 let mut out: Vec<(f64, String)> = (0..n)
451 .map(|i| {
452 let center = (self.breaks[i] + self.breaks[i + 1]) / 2.0;
453 ((center - lo0) / span, self.class_label(i))
454 })
455 .collect();
456 if self.na_key_shown() {
457 out.push((1.0, self.na_label_with_count()));
458 }
459 out
460 }
461
462 fn breaks_for_layout(&self) -> Vec<(f64, String)> {
467 match self.count_reserve.filter(|_| self.show_counts) {
468 None => self.breaks(),
469 Some(reserve) => {
470 let min_digits = reserve.to_string().len();
471 self.breaks()
472 .into_iter()
473 .map(|(pos, label)| (pos, Self::pad_trailing_count(&label, min_digits)))
474 .collect()
475 }
476 }
477 }
478
479 fn name(&self) -> &str {
480 &self.name
481 }
482
483 fn set_name(&mut self, name: &str) {
484 self.name = name.to_string();
485 }
486
487 fn is_discrete(&self) -> bool {
488 true
489 }
490
491 fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
492 if matches!(value, Value::Na) {
493 return Some(self.na_color);
494 }
495 if let Some(f) = value.as_f64() {
496 if f.is_finite() {
497 return Some(self.colors[self.bin_index(f)]);
498 }
499 }
500 if let Value::Str(s) = value {
501 if s == &self.na_label_with_count() {
502 return Some(self.na_color);
503 }
504 for i in 0..self.colors.len() {
505 if self.class_label(i) == *s {
506 return Some(self.colors[i]);
507 }
508 }
509 }
510 None
511 }
512
513 fn domain(&self) -> Option<(f64, f64)> {
514 let n = self.colors.len();
515 Some((self.breaks[0], self.breaks[n]))
516 }
517
518 fn na_fill_pattern(&self) -> Option<FillPattern> {
524 self.na_pattern
525 }
526
527 fn is_na_break(&self, label: &str) -> bool {
528 self.na_key_shown() && label == self.na_label_with_count()
529 }
530
531 fn validate(&self, warnings: &mut Vec<String>) {
532 if self.corrected {
533 warnings.push(
534 "scale_fill_binned: breaks need >= 2 increasing finite edges, with one colour \
535 per bin — falling back to a default range/palette"
536 .to_string(),
537 );
538 }
539 }
540
541 fn clone_box(&self) -> Box<dyn Scale> {
542 Box::new(self.clone())
543 }
544
545 fn reset_training(&mut self) {
546 self.counts.iter_mut().for_each(|c| *c = 0);
547 self.na_count = 0;
548 self.seen.clear();
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn scale() -> ScaleFillBinned {
557 ScaleFillBinned::new(
558 Aesthetic::Fill,
559 vec![0.0, 10.0, 50.0, 100.0],
560 vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
561 )
562 }
563
564 #[test]
565 fn bins_are_half_open_except_the_last() {
566 let s = scale();
567 assert_eq!(s.bin_index(0.0), 0);
568 assert_eq!(s.bin_index(9.999), 0);
569 assert_eq!(s.bin_index(10.0), 1); assert_eq!(s.bin_index(49.999), 1);
571 assert_eq!(s.bin_index(50.0), 2);
572 assert_eq!(s.bin_index(100.0), 2); assert_eq!(
574 s.bin_index(1000.0),
575 2,
576 "out-of-range clamps to the last bin"
577 );
578 assert_eq!(
579 s.bin_index(-50.0),
580 0,
581 "out-of-range clamps to the first bin"
582 );
583 }
584
585 #[test]
586 fn train_tallies_counts_and_na_separately() {
587 let mut s = scale();
588 s.train(&[
589 Value::Float(1.0),
590 Value::Float(5.0),
591 Value::Float(20.0),
592 Value::Float(99.0),
593 Value::Float(100.0),
594 Value::Na,
595 Value::Na,
596 ]);
597 assert_eq!(s.counts, vec![2, 1, 2]);
598 assert_eq!(s.na_count, 2);
599 }
600
601 #[test]
607 fn training_the_same_batch_twice_does_not_double_count() {
608 let batch = [Value::Float(1.0), Value::Float(20.0), Value::Na];
609 let mut s = scale();
610 s.train(&batch);
611 s.train(&batch); assert_eq!(s.counts, vec![1, 1, 0]);
613 assert_eq!(s.na_count, 1);
614 s.train(&[Value::Float(60.0)]);
616 assert_eq!(s.counts, vec![1, 1, 1]);
617 }
618
619 #[test]
620 fn label_formatter_formats_both_edges_not_the_count() {
621 let mut s = ScaleFillBinned::new(
623 Aesthetic::Fill,
624 vec![140.0, 2_657_637.0, 2_657_637.0, 1_397_715_000.0],
625 vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
626 )
627 .with_label_formatter(|v| {
628 if v >= 1e9 {
629 format!("{:.1}B", v / 1e9)
630 } else if v >= 1e6 {
631 format!("{:.1}M", v / 1e6)
632 } else {
633 format!("{v}")
634 }
635 });
636 s.train(&[Value::Float(500.0), Value::Float(2_657_637.0)]);
637 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
638 assert_eq!(labels, vec!["140–2.7M (1)", "2.7M (1)", "2.7M–1.4B (0)"]);
639 let s = s.show_counts(false);
640 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
641 assert_eq!(labels, vec!["140–2.7M", "2.7M", "2.7M–1.4B"]);
642 assert!(format!("{s:?}").contains("label_formatter: Some(LabelFormatter)"));
643 }
644
645 #[test]
646 fn breaks_label_each_class_with_its_count_and_na_with_its_own() {
647 let mut s = scale();
648 s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
649 let breaks = s.breaks();
650 let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
651 assert_eq!(
652 labels,
653 vec!["0–10 (1)", "10–50 (1)", "50–100 (0)", "no data (1)"]
654 );
655 }
656
657 #[test]
658 fn no_na_key_when_nothing_is_missing() {
659 let mut s = scale();
660 s.train(&[Value::Float(1.0)]);
661 assert_eq!(s.breaks().len(), 3, "no trailing no-data entry");
662 }
663
664 #[test]
665 fn map_to_color_matches_the_bin_and_roundtrips_legend_labels() {
666 let mut s = scale();
667 s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
668 assert_eq!(s.map_to_color(&Value::Float(5.0)), Some((0, 0, 255)));
669 assert_eq!(s.map_to_color(&Value::Float(20.0)), Some((0, 255, 0)));
670 assert_eq!(s.map_to_color(&Value::Float(75.0)), Some((255, 0, 0)));
671 assert_eq!(s.map_to_color(&Value::Na), Some((127, 127, 127)));
672 for (_, label) in s.breaks() {
673 let via_label = s.map_to_color(&Value::Str(label.clone()));
674 assert!(via_label.is_some(), "{label} should round-trip to a colour");
675 }
676 }
677
678 #[test]
679 fn na_color_and_label_are_configurable() {
680 let mut s = scale().na_color((10, 20, 30)).na_label("missing");
681 s.train(&[Value::Na]);
682 assert_eq!(s.map_to_color(&Value::Na), Some((10, 20, 30)));
683 assert!(s.breaks().iter().any(|(_, l)| l == "missing (1)"));
684 assert_eq!(
685 s.map_to_color(&Value::Str("missing (1)".to_string())),
686 Some((10, 20, 30))
687 );
688 }
689
690 #[test]
691 fn na_pattern_is_none_until_set() {
692 let s = scale();
693 assert_eq!(s.na_fill_pattern(), None);
694 }
695
696 #[test]
697 fn na_pattern_is_reported_only_for_the_no_data_break_label() {
698 let pattern = FillPattern::stripe((10, 20, 30), (200, 210, 220));
699 let mut s = scale().na_pattern(pattern);
700 s.train(&[Value::Na, Value::Float(5.0)]);
701 assert_eq!(s.na_fill_pattern(), Some(pattern));
702 assert!(s.is_na_break(&s.na_label_with_count()));
703 let ordinary = s.class_label(0);
705 assert!(!s.is_na_break(&ordinary));
706 }
707
708 #[test]
709 fn no_na_break_when_nothing_trained_as_na() {
710 let s = scale().na_pattern(FillPattern::stripe((1, 2, 3), (4, 5, 6)));
713 assert!(!s.is_na_break(&s.na_label_with_count()));
714 }
715
716 #[test]
717 fn two_interpolates_colors_across_bins() {
718 let s = ScaleFillBinned::two(
719 Aesthetic::Fill,
720 vec![0.0, 25.0, 50.0, 75.0, 100.0],
721 (0, 0, 0),
722 (255, 255, 255),
723 );
724 assert_eq!(s.colors[0], (0, 0, 0));
725 assert_eq!(s.colors[3], (255, 255, 255));
726 assert!(s.colors[1].0 < s.colors[2].0);
727 }
728
729 #[test]
730 fn mismatched_colors_fall_back_and_warn() {
731 let s = ScaleFillBinned::new(
732 Aesthetic::Fill,
733 vec![0.0, 10.0, 20.0],
734 vec![(0, 0, 0)], );
736 assert_eq!(s.colors.len(), 2);
737 let mut warnings = Vec::new();
738 s.validate(&mut warnings);
739 assert_eq!(warnings.len(), 1);
740 }
741
742 #[test]
743 fn too_few_breaks_falls_back_to_a_single_bin_rather_than_panicking() {
744 let s = ScaleFillBinned::new(Aesthetic::Fill, vec![5.0], vec![]);
745 assert_eq!(s.breaks.len(), 2);
746 assert_eq!(s.colors.len(), 1);
747 let mut warnings = Vec::new();
748 s.validate(&mut warnings);
749 assert_eq!(warnings.len(), 1);
750 }
751
752 #[test]
753 fn domain_spans_the_outer_edges() {
754 let s = scale();
755 assert_eq!(s.domain(), Some((0.0, 100.0)));
756 }
757
758 #[test]
759 fn reset_training_clears_counts_but_keeps_breaks_and_colors() {
760 let mut s = scale();
761 s.train(&[Value::Float(1.0), Value::Na]);
762 s.reset_training();
763 assert_eq!(s.counts, vec![0, 0, 0]);
764 assert_eq!(s.na_count, 0);
765 assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
766 }
767
768 #[test]
774 fn duplicate_edge_at_the_max_keeps_both_classes_and_colours() {
775 let mut s = ScaleFillBinned::new(
776 Aesthetic::Fill,
777 vec![35.0, 150.0, 150.0],
778 vec![(0, 0, 255), (255, 0, 0)],
779 );
780 assert!(
781 !s.corrected,
782 "a legitimate zero-width bin is not a fallback"
783 );
784 assert_eq!(s.colors.len(), 2, "caller's requested class count is kept");
785 assert_eq!(
786 s.colors,
787 vec![(0, 0, 255), (255, 0, 0)],
788 "distinct colours per class"
789 );
790
791 s.train(&[Value::Float(35.0), Value::Float(150.0)]);
792 assert_eq!(s.counts, vec![1, 1], "each cell counted into its own class");
793 assert_eq!(s.bin_index(35.0), 0);
794 assert_eq!(
795 s.bin_index(150.0),
796 1,
797 "the zero-width bin claims its own edge value"
798 );
799 assert_eq!(s.map_to_color(&Value::Float(150.0)), Some((255, 0, 0)));
800
801 let breaks = s.breaks();
802 let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
803 assert_eq!(
804 labels,
805 vec!["35–150 (1)", "150 (1)"],
806 "the zero-width class's label/count is a single value, not a 150–150 range"
807 );
808 }
809
810 #[test]
813 fn duplicate_edge_at_the_min_keeps_both_classes_and_colours() {
814 let mut s = ScaleFillBinned::new(
815 Aesthetic::Fill,
816 vec![0.0, 0.0, 10.0],
817 vec![(0, 0, 255), (255, 0, 0)],
818 );
819 assert!(!s.corrected);
820 assert_eq!(s.colors.len(), 2);
821
822 s.train(&[Value::Float(0.0), Value::Float(0.0), Value::Float(5.0)]);
823 assert_eq!(s.counts, vec![2, 1]);
824 assert_eq!(s.bin_index(0.0), 0, "the zero-width bin claims the minimum");
825 assert_eq!(s.bin_index(5.0), 1);
826 assert_eq!(s.map_to_color(&Value::Float(0.0)), Some((0, 0, 255)));
827
828 let breaks = s.breaks();
829 let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
830 assert_eq!(labels, vec!["0 (2)", "0–10 (1)"]);
831 }
832
833 #[test]
836 fn duplicate_edge_in_the_middle_keeps_all_classes_and_colours() {
837 let mut s = ScaleFillBinned::new(
838 Aesthetic::Fill,
839 vec![0.0, 50.0, 50.0, 100.0],
840 vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
841 );
842 assert!(!s.corrected);
843 assert_eq!(s.colors.len(), 3, "all three requested classes survive");
844 assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
845
846 s.train(&[Value::Float(25.0), Value::Float(50.0), Value::Float(75.0)]);
847 assert_eq!(
848 s.counts,
849 vec![1, 1, 1],
850 "the shared edge value goes to the middle (zero-width) bin"
851 );
852 assert_eq!(s.bin_index(25.0), 0);
853 assert_eq!(
854 s.bin_index(50.0),
855 1,
856 "the zero-width middle bin wins the tie over its neighbours"
857 );
858 assert_eq!(s.bin_index(75.0), 2);
859 assert_eq!(s.map_to_color(&Value::Float(50.0)), Some((0, 255, 0)));
860
861 let breaks = s.breaks();
862 let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
863 assert_eq!(labels, vec!["0–50 (1)", "50 (1)", "50–100 (1)"]);
864 }
865
866 #[test]
870 fn non_degenerate_edges_are_unaffected() {
871 let s = scale();
872 assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
873 assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
874 assert_eq!(s.bin_index(0.0), 0);
875 assert_eq!(s.bin_index(9.999), 0);
876 assert_eq!(s.bin_index(10.0), 1);
877 assert_eq!(s.bin_index(49.999), 1);
878 assert_eq!(s.bin_index(50.0), 2);
879 assert_eq!(s.bin_index(100.0), 2);
880 }
881
882 #[test]
885 fn na_key_always_lists_the_key_with_zero_count_when_nothing_is_missing() {
886 let mut s = scale().na_key(NaKey::Always);
887 s.train(&[Value::Float(1.0)]);
888 let breaks = s.breaks();
889 assert_eq!(breaks.len(), 4, "the no-data key is kept even at 0");
890 let (_, label) = breaks.last().unwrap();
891 assert_eq!(label, "no data (0)");
892 assert_eq!(
894 s.map_to_color(&Value::Str(label.clone())),
895 Some((127, 127, 127))
896 );
897 assert!(s.is_na_break(label));
898 }
899
900 #[test]
901 fn na_key_never_hides_it_even_with_na_present() {
902 let mut s = scale().na_key(NaKey::Never);
903 s.train(&[Value::Float(1.0), Value::Na, Value::Na]);
904 let breaks = s.breaks();
905 assert_eq!(breaks.len(), 3, "no trailing no-data entry despite 2 NAs");
906 assert!(!s.is_na_break(&s.na_label_with_count()));
907 }
908
909 #[test]
910 fn na_key_auto_is_unchanged() {
911 let mut without_na = scale();
913 without_na.train(&[Value::Float(1.0)]);
914 assert_eq!(without_na.breaks().len(), 3);
915
916 let mut with_na = scale();
917 with_na.train(&[Value::Float(1.0), Value::Na]);
918 assert_eq!(with_na.breaks().len(), 4);
919 }
920
921 #[test]
924 fn count_reserve_pads_measured_labels_but_not_drawn_ones() {
925 let mut few = scale().count_reserve(9999);
926 few.train(&[Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)]);
927 let drawn = few.breaks();
928 let labels: Vec<&str> = drawn.iter().map(|(_, l)| l.as_str()).collect();
929 assert_eq!(
930 labels,
931 vec!["0–10 (1)", "10–50 (1)", "50–100 (1)"],
932 "drawn labels unaffected"
933 );
934
935 let layout = few.breaks_for_layout();
936 let layout_labels: Vec<&str> = layout.iter().map(|(_, l)| l.as_str()).collect();
937 assert_eq!(
938 layout_labels,
939 vec!["0–10 (0001)", "10–50 (0001)", "50–100 (0001)"],
940 "layout labels reserve the 4-digit width"
941 );
942 }
943
944 #[test]
951 fn count_reserve_gives_equal_length_layout_labels_across_differing_counts() {
952 let mut few = scale().count_reserve(9999);
953 few.train(&[Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)]);
954
955 let mut many = scale().count_reserve(9999);
956 let batch: Vec<Value> = (0..1234)
957 .flat_map(|_| [Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)])
958 .collect();
959 many.train(&batch);
960
961 let few_layout = few.breaks_for_layout();
962 let many_layout = many.breaks_for_layout();
963 for (f, m) in few_layout.iter().zip(many_layout.iter()) {
964 assert_eq!(
965 f.1.len(),
966 m.1.len(),
967 "layout labels must measure the same width: {f:?} vs {m:?}"
968 );
969 }
970 assert_ne!(few.breaks(), many.breaks());
972 }
973
974 #[test]
975 fn count_reserve_is_none_by_default_and_layout_matches_breaks() {
976 let mut s = scale();
977 s.train(&[Value::Float(1.0), Value::Na]);
978 assert_eq!(s.breaks_for_layout(), s.breaks());
979 }
980
981 #[test]
982 fn count_reserve_never_shrinks_below_the_actual_count() {
983 let mut s = scale().count_reserve(1);
986 let batch: Vec<Value> = (0..1234).map(|_| Value::Float(1.0)).collect();
987 s.train(&batch);
988 let layout = s.breaks_for_layout();
989 assert!(layout[0].1.contains("(1234)"), "{layout:?}");
990 }
991}