1mod colors;
10mod dispatch;
11mod dsv;
12mod types;
13
14pub use dispatch::{DispatchError, DispatchOutput, ProcessingError, dispatch};
15pub use dsv::{Data, DsvError, DsvOptions, parse_dsv, parse_dsv_reader};
16pub use types::{
17 Command, CountedValues, Format, MultiFormat, MultiSeriesData, Options, Parameters, SeriesXY,
18 SingleFormat, SingleSeriesData,
19};
20
21#[cfg(test)]
22pub(crate) use colors::ColorsRenderable;
23#[cfg(test)]
24pub(crate) use dispatch::{
25 count_values, dispatch_multi_series, dispatch_single_series, header_at,
26 infer_multi_series_xlabel, parse_numeric_series_lenient,
27};
28#[cfg(test)]
29pub(crate) use dsv::transpose_jagged;
30
31#[cfg(test)]
32mod tests {
33 use std::io;
34
35 use super::{
36 ColorsRenderable, Command, Data, DispatchError, DsvOptions, Format, MultiFormat,
37 MultiSeriesData, Options, Parameters, SeriesXY, SingleFormat, SingleSeriesData,
38 count_values, dispatch, dispatch_multi_series, dispatch_single_series, header_at,
39 infer_multi_series_xlabel, parse_dsv, parse_dsv_reader, parse_numeric_series_lenient,
40 transpose_jagged,
41 };
42 use unicode_plot::ClosedInterval;
43 use unicode_plot::border::BorderType;
44 use unicode_plot::canvas::{CanvasType, Scale};
45 use unicode_plot::color::{ColorMode, NamedColor, TermColor};
46
47 fn parse_with(input: &str, headers: bool, transpose: bool) -> Data {
48 parse_dsv(
49 input,
50 DsvOptions {
51 delimiter: b',',
52 headers,
53 transpose,
54 },
55 )
56 .expect("parsing test fixture input should succeed")
57 }
58
59 fn some_rows(rows: &[&[&str]]) -> Vec<Vec<Option<String>>> {
60 rows.iter()
61 .map(|row| row.iter().map(|value| Some((*value).to_owned())).collect())
62 .collect()
63 }
64
65 #[test]
66 fn transpose_jagged_square() {
67 let rows = some_rows(&[&["a", "b", "c"], &["d", "e", "f"], &["g", "h", "i"]]);
68 let transposed = transpose_jagged(&rows);
69 let expected = some_rows(&[&["a", "d", "g"], &["b", "e", "h"], &["c", "f", "i"]]);
70 assert_eq!(transposed, expected);
71 }
72
73 #[test]
74 fn transpose_jagged_decreasing_widths() {
75 let rows = vec![
76 vec![
77 Some(String::from("a")),
78 Some(String::from("b")),
79 Some(String::from("c")),
80 ],
81 vec![Some(String::from("d")), Some(String::from("e"))],
82 vec![Some(String::from("f"))],
83 ];
84 let transposed = transpose_jagged(&rows);
85 assert_eq!(
86 transposed,
87 vec![
88 vec![
89 Some(String::from("a")),
90 Some(String::from("d")),
91 Some(String::from("f"))
92 ],
93 vec![Some(String::from("b")), Some(String::from("e")), None],
94 vec![Some(String::from("c")), None, None],
95 ]
96 );
97 }
98
99 #[test]
100 fn transpose_jagged_increasing_widths() {
101 let rows = vec![
102 vec![Some(String::from("a"))],
103 vec![Some(String::from("b")), Some(String::from("c"))],
104 vec![
105 Some(String::from("d")),
106 Some(String::from("e")),
107 Some(String::from("f")),
108 ],
109 ];
110 let transposed = transpose_jagged(&rows);
111 assert_eq!(
112 transposed,
113 vec![
114 vec![
115 Some(String::from("a")),
116 Some(String::from("b")),
117 Some(String::from("d"))
118 ],
119 vec![None, Some(String::from("c")), Some(String::from("e"))],
120 vec![None, None, Some(String::from("f"))],
121 ]
122 );
123 }
124
125 #[test]
126 fn headers_matrix_matches_expected_cases() {
127 let dataset = "h1,h2,h3\na,b,c\nd,e\nf\n";
128 let cases = [
129 (dataset, false, false, None),
130 (dataset, false, true, None),
131 (
132 dataset,
133 true,
134 false,
135 Some(vec![
136 Some(String::from("h1")),
137 Some(String::from("h2")),
138 Some(String::from("h3")),
139 ]),
140 ),
141 (
142 dataset,
143 true,
144 true,
145 Some(vec![
146 Some(String::from("h1")),
147 Some(String::from("a")),
148 Some(String::from("d")),
149 Some(String::from("f")),
150 ]),
151 ),
152 ("", false, false, None),
153 ("", false, true, None),
154 ("", true, false, Some(vec![])),
155 ("", true, true, Some(vec![])),
156 (
157 "colA,colB\n1,2\n",
158 true,
159 false,
160 Some(vec![Some(String::from("colA")), Some(String::from("colB"))]),
161 ),
162 ];
163
164 for (input, headers, transpose, expected_headers) in cases {
165 let parsed = parse_with(input, headers, transpose);
166 assert_eq!(
167 parsed.headers, expected_headers,
168 "headers mismatch for input={input:?}, headers={headers}, transpose={transpose}",
169 );
170 }
171 }
172
173 #[test]
174 fn series_matrix_main_cases() {
175 let dataset = "h1,h2,h3\na,b,c\nd,e\nf\n";
176 let cases = [
177 (
178 false,
179 false,
180 vec![
181 vec![
182 Some(String::from("h1")),
183 Some(String::from("a")),
184 Some(String::from("d")),
185 Some(String::from("f")),
186 ],
187 vec![
188 Some(String::from("h2")),
189 Some(String::from("b")),
190 Some(String::from("e")),
191 None,
192 ],
193 vec![
194 Some(String::from("h3")),
195 Some(String::from("c")),
196 None,
197 None,
198 ],
199 ],
200 ),
201 (
202 false,
203 true,
204 vec![
205 vec![
206 Some(String::from("h1")),
207 Some(String::from("h2")),
208 Some(String::from("h3")),
209 ],
210 vec![
211 Some(String::from("a")),
212 Some(String::from("b")),
213 Some(String::from("c")),
214 ],
215 vec![Some(String::from("d")), Some(String::from("e"))],
216 vec![Some(String::from("f"))],
217 ],
218 ),
219 (
220 true,
221 false,
222 vec![
223 vec![
224 Some(String::from("a")),
225 Some(String::from("d")),
226 Some(String::from("f")),
227 ],
228 vec![Some(String::from("b")), Some(String::from("e")), None],
229 vec![Some(String::from("c")), None, None],
230 ],
231 ),
232 (
233 true,
234 true,
235 vec![
236 vec![Some(String::from("h2")), Some(String::from("h3"))],
237 vec![Some(String::from("b")), Some(String::from("c"))],
238 vec![Some(String::from("e"))],
239 vec![],
240 ],
241 ),
242 ];
243
244 for (headers, transpose, expected_series) in cases {
245 let parsed = parse_with(dataset, headers, transpose);
246 assert_eq!(
247 parsed.series, expected_series,
248 "series mismatch for headers={headers}, transpose={transpose}",
249 );
250 }
251 }
252
253 #[test]
254 fn series_matrix_empty_input_cases() {
255 for (headers, transpose) in [(false, false), (false, true), (true, false), (true, true)] {
256 let parsed = parse_with("", headers, transpose);
257 assert_eq!(parsed.series, Vec::<Vec<Option<String>>>::new());
258 }
259 }
260
261 #[test]
262 fn series_matrix_jagged_cases() {
263 let parsed = parse_with("1,2\n3\n", false, false);
264 assert_eq!(
265 parsed.series,
266 vec![
267 vec![Some(String::from("1")), Some(String::from("3"))],
268 vec![Some(String::from("2")), None],
269 ]
270 );
271
272 let parsed = parse_with("1,2\n3\n", true, false);
273 assert_eq!(parsed.series, vec![vec![Some(String::from("3"))]]);
274 }
275
276 #[test]
277 fn series_matrix_transpose_header_cases() {
278 let parsed = parse_with("a,b\nc,d\n", true, true);
279 assert_eq!(
280 parsed.series,
281 vec![vec![Some(String::from("b"))], vec![Some(String::from("d"))]],
282 );
283
284 let parsed = parse_with("x\ny\nz\n", false, false);
285 assert_eq!(
286 parsed.series,
287 vec![vec![
288 Some(String::from("x")),
289 Some(String::from("y")),
290 Some(String::from("z")),
291 ]],
292 );
293 }
294
295 #[test]
296 fn parse_dsv_uses_tab_delimiter_by_default() {
297 let parsed = parse_dsv("a\tb\n1\t2\n", DsvOptions::default())
298 .expect("default-tab parsing should succeed");
299 assert_eq!(
300 parsed.series,
301 vec![
302 vec![Some(String::from("a")), Some(String::from("1"))],
303 vec![Some(String::from("b")), Some(String::from("2"))],
304 ],
305 );
306 }
307
308 #[test]
309 fn parse_dsv_respects_custom_delimiter() {
310 let parsed = parse_dsv(
311 "a;b\n1;2\n",
312 DsvOptions {
313 delimiter: b';',
314 ..DsvOptions::default()
315 },
316 )
317 .expect("custom-delimiter parsing should succeed");
318 assert_eq!(
319 parsed.series,
320 vec![
321 vec![Some(String::from("a")), Some(String::from("1"))],
322 vec![Some(String::from("b")), Some(String::from("2"))],
323 ],
324 );
325 }
326
327 #[test]
328 fn parse_dsv_reports_csv_errors() {
329 struct FailingReader;
330
331 impl io::Read for FailingReader {
332 fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
333 Err(io::Error::other("injected read error"))
334 }
335 }
336
337 let result = parse_dsv_reader(FailingReader, DsvOptions::default());
338 assert!(
339 result.is_err(),
340 "reader failures should surface as parser errors"
341 );
342 }
343
344 fn some_option_strings(values: &[&str]) -> Vec<Option<String>> {
345 values
346 .iter()
347 .map(|value| Some((*value).to_owned()))
348 .collect()
349 }
350
351 fn render_plain(command: Command, data: &Data, params: &Parameters) -> String {
352 let output = dispatch(command, data, params).expect("dispatch should succeed");
353 let mut buffer = Vec::new();
354 output
355 .render_with_mode(&mut buffer, ColorMode::Never, false)
356 .expect("rendering should succeed");
357 String::from_utf8(buffer).expect("rendered output should be valid UTF-8")
358 }
359
360 #[test]
361 fn count_values_sorts_descending_then_alphabetical() {
362 let values = some_option_strings(&["a", "a", "a", "b", "b", "c"]);
363 let counted = count_values(&values, false);
364 assert_eq!(counted.labels, vec!["a", "b", "c"]);
365 assert_eq!(counted.counts, vec![3, 2, 1]);
366 }
367
368 #[test]
369 fn count_values_non_tally_equivalent_behavior() {
370 let values = some_option_strings(&["c", "a", "b", "a", "c", "c"]);
371 let counted = count_values(&values, false);
372 assert_eq!(counted.labels, vec!["c", "a", "b"]);
373 assert_eq!(counted.counts, vec![3, 2, 1]);
374 }
375
376 #[test]
377 fn count_values_reverse_reverses_sorted_order() {
378 let values = some_option_strings(&["a", "a", "a", "b", "b", "c"]);
379 let counted = count_values(&values, true);
380 assert_eq!(counted.labels, vec!["c", "b", "a"]);
381 assert_eq!(counted.counts, vec![1, 2, 3]);
382 }
383
384 #[test]
385 fn count_values_tie_breaks_alphabetically() {
386 let values = some_option_strings(&["b", "a", "c", "c", "b", "a"]);
387 let counted = count_values(&values, false);
388 assert_eq!(counted.labels, vec!["a", "b", "c"]);
389 assert_eq!(counted.counts, vec![2, 2, 2]);
390 }
391
392 #[test]
393 fn dispatch_single_series_single_column_is_implicit_y() {
394 let data = Data {
395 headers: None,
396 series: vec![some_option_strings(&["1.0", "2.5", "3.25"])],
397 };
398
399 let result = dispatch_single_series(&data, SingleFormat::Xy)
400 .expect("single-column dispatch should succeed");
401 assert_eq!(result, SingleSeriesData::ImplicitY(vec![1.0, 2.5, 3.25]));
402 }
403
404 #[test]
405 fn dispatch_single_series_two_column_xy_maps_labels_and_values() {
406 let data = Data {
407 headers: None,
408 series: vec![
409 some_option_strings(&["alpha", "beta", "gamma"]),
410 some_option_strings(&["1.0", "2.0", "3.0"]),
411 ],
412 };
413
414 let result =
415 dispatch_single_series(&data, SingleFormat::Xy).expect("xy dispatch should succeed");
416 assert_eq!(
417 result,
418 SingleSeriesData::ExplicitXY {
419 labels: vec![
420 String::from("alpha"),
421 String::from("beta"),
422 String::from("gamma"),
423 ],
424 values: vec![1.0, 2.0, 3.0],
425 }
426 );
427 }
428
429 #[test]
430 fn dispatch_single_series_two_column_yx_swaps_columns() {
431 let data = Data {
432 headers: None,
433 series: vec![
434 some_option_strings(&["1.0", "2.0", "3.0"]),
435 some_option_strings(&["alpha", "beta", "gamma"]),
436 ],
437 };
438
439 let result =
440 dispatch_single_series(&data, SingleFormat::Yx).expect("yx dispatch should succeed");
441 assert_eq!(
442 result,
443 SingleSeriesData::ExplicitXY {
444 labels: vec![
445 String::from("alpha"),
446 String::from("beta"),
447 String::from("gamma"),
448 ],
449 values: vec![1.0, 2.0, 3.0],
450 }
451 );
452 }
453
454 #[test]
455 fn dispatch_multi_series_xyy_uses_shared_x() {
456 let data = Data {
457 headers: Some(some_option_strings(&["x", "y1", "y2"])),
458 series: vec![
459 some_option_strings(&["1.0", "2.0", "3.0"]),
460 some_option_strings(&["10.0", "20.0", "30.0"]),
461 some_option_strings(&["100.0", "200.0", "300.0"]),
462 ],
463 };
464
465 let result =
466 dispatch_multi_series(&data, MultiFormat::Xyy).expect("xyy dispatch should succeed");
467 assert_eq!(
468 result,
469 MultiSeriesData::SharedX {
470 x: vec![1.0, 2.0, 3.0],
471 ys: vec![vec![10.0, 20.0, 30.0], vec![100.0, 200.0, 300.0]],
472 names: vec![Some(String::from("y1")), Some(String::from("y2"))],
473 }
474 );
475 }
476
477 #[test]
478 fn dispatch_multi_series_xyxy_builds_paired_series() {
479 let data = Data {
480 headers: Some(some_option_strings(&["x1", "s1", "x2", "s2"])),
481 series: vec![
482 some_option_strings(&["1.0", "2.0", "3.0"]),
483 some_option_strings(&["10.0", "20.0", "30.0"]),
484 some_option_strings(&["4.0", "5.0", "6.0"]),
485 some_option_strings(&["40.0", "50.0", "60.0"]),
486 ],
487 };
488
489 let result =
490 dispatch_multi_series(&data, MultiFormat::Xyxy).expect("xyxy dispatch should succeed");
491 assert_eq!(
492 result,
493 MultiSeriesData::PairedXY(vec![
494 SeriesXY {
495 x: vec![1.0, 2.0, 3.0],
496 y: vec![10.0, 20.0, 30.0],
497 name: Some(String::from("s1")),
498 },
499 SeriesXY {
500 x: vec![4.0, 5.0, 6.0],
501 y: vec![40.0, 50.0, 60.0],
502 name: Some(String::from("s2")),
503 },
504 ])
505 );
506 }
507
508 #[test]
509 fn dispatch_multi_series_requires_enough_columns_for_xyy() {
510 let data = Data {
511 headers: None,
512 series: vec![some_option_strings(&["1.0", "2.0"])],
513 };
514
515 let error = dispatch_multi_series(&data, MultiFormat::Xyy)
516 .expect_err("xyy should reject single-column input");
517 assert!(matches!(
518 error,
519 super::ProcessingError::NotEnoughColumns {
520 required: 2,
521 found: 1
522 }
523 ));
524 }
525
526 #[test]
527 fn dispatch_multi_series_xyxy_requires_even_number_of_columns() {
528 let data = Data {
529 headers: None,
530 series: vec![
531 some_option_strings(&["1.0", "2.0"]),
532 some_option_strings(&["10.0", "20.0"]),
533 some_option_strings(&["3.0", "4.0"]),
534 ],
535 };
536
537 let error = dispatch_multi_series(&data, MultiFormat::Xyxy)
538 .expect_err("xyxy should reject odd column count");
539 assert!(matches!(
540 error,
541 super::ProcessingError::OddSeriesCount { column_count: 3 }
542 ));
543 }
544
545 #[test]
546 fn dispatch_single_series_rejects_missing_labels_with_user_indexes() {
547 let data = Data {
548 headers: None,
549 series: vec![
550 some_option_strings(&["1.0", "2.0"]),
551 vec![Some(String::from("2.0")), None],
552 ],
553 };
554
555 let error = dispatch_single_series(&data, SingleFormat::Yx)
556 .expect_err("missing labels should be rejected");
557 assert!(matches!(
558 error,
559 super::ProcessingError::MissingValue { column: 2, row: 2 }
560 ));
561 }
562
563 #[test]
564 fn options_default_matches_expected_shape() {
565 let options = Options::default();
566 assert_eq!(options.delimiter, b'\t');
567 assert!(!options.transpose);
568 assert!(!options.headers);
569 assert_eq!(options.encoding, None);
570 }
571
572 #[test]
573 fn parameter_conversion_maps_barplot_fields() {
574 let params = Parameters {
575 title: Some(String::from("Title")),
576 width: Some(64),
577 border: Some(BorderType::Corners),
578 color: Some(TermColor::Named(NamedColor::Cyan)),
579 symbol: Some('@'),
580 xscale: Some(Scale::Log10),
581 labels: Some(false),
582 ..Parameters::default()
583 };
584
585 let options = params.to_barplot_options();
586 assert_eq!(options.title.as_deref(), Some("Title"));
587 assert_eq!(options.width, 64);
588 assert_eq!(options.border, BorderType::Corners);
589 assert_eq!(options.color, TermColor::Named(NamedColor::Cyan));
590 assert_eq!(options.symbol, Some('@'));
591 assert_eq!(options.xscale, Scale::Log10);
592 assert!(!options.labels);
593 }
594
595 #[test]
596 fn parameter_conversion_maps_grid_plot_fields() {
597 let params = Parameters {
598 width: Some(32),
599 height: Some(12),
600 xlim: Some((-1.0, 10.0)),
601 ylim: Some((0.0, 100.0)),
602 canvas: Some(CanvasType::Dot),
603 grid: Some(false),
604 closed: Some(ClosedInterval::Right),
605 ..Parameters::default()
606 };
607
608 let line = params.to_lineplot_options();
609 assert_eq!(line.width, 32);
610 assert_eq!(line.height, 12);
611 assert_eq!(line.xlim, (-1.0, 10.0));
612 assert_eq!(line.ylim, (0.0, 100.0));
613 assert_eq!(line.canvas, CanvasType::Dot);
614 assert!(!line.grid);
615
616 let histogram = params.to_histogram_options();
617 assert_eq!(histogram.closed, ClosedInterval::Right);
618 }
619
620 #[test]
621 fn dispatch_barplot_rejects_single_column_data() {
622 let data = Data {
623 headers: None,
624 series: vec![some_option_strings(&["1.0", "2.0", "3.0"])],
625 };
626
627 let Err(error) = dispatch(Command::Bar, &data, &Parameters::default()) else {
628 panic!("barplot command should reject single-column input");
629 };
630 assert!(matches!(
631 error,
632 DispatchError::ExplicitSeriesRequired { command: "barplot" }
633 ));
634 }
635
636 #[test]
637 fn dispatch_multi_series_commands_reject_single_column_data() {
638 let data = Data {
639 headers: None,
640 series: vec![some_option_strings(&["1.0", "2.0", "3.0"])],
641 };
642
643 for command in [Command::Lineplots, Command::Scatter, Command::Density] {
644 let Err(error) = dispatch(command, &data, &Parameters::default()) else {
645 panic!("multi-series command should reject single-column input");
646 };
647 assert!(matches!(error, DispatchError::MultiSeriesRequired { .. }));
648 }
649 }
650
651 #[test]
652 fn dispatch_respects_format_command_compatibility() {
653 let data = Data {
654 headers: None,
655 series: vec![
656 some_option_strings(&["x1", "x2"]),
657 some_option_strings(&["1.0", "2.0"]),
658 ],
659 };
660
661 let params = Parameters {
662 fmt: Some(Format::Xyy),
663 ..Parameters::default()
664 };
665 let Err(error) = dispatch(Command::Bar, &data, ¶ms) else {
666 panic!("barplot should reject multi-series format markers");
667 };
668 assert!(matches!(
669 error,
670 DispatchError::FormatMismatch {
671 command: Command::Bar,
672 format: Format::Xyy,
673 }
674 ));
675 }
676
677 #[test]
678 fn count_dispatch_overwrites_explicit_title_with_header() {
679 let data = Data {
680 headers: Some(vec![Some(String::from("Species"))]),
681 series: vec![some_option_strings(&["setosa", "setosa", "virginica"])],
682 };
683 let params = Parameters {
684 title: Some(String::from("EXPLICIT")),
685 ..Parameters::default()
686 };
687
688 let rendered = render_plain(Command::Count, &data, ¶ms);
689 assert!(
690 rendered.contains("Species"),
691 "count title should be overwritten by first header"
692 );
693 assert!(
694 !rendered.contains("EXPLICIT"),
695 "explicit title should not be preserved for count"
696 );
697 }
698
699 #[test]
700 fn bar_dispatch_keeps_explicit_title() {
701 let data = Data {
702 headers: Some(vec![
703 Some(String::from("Name")),
704 Some(String::from("Value")),
705 ]),
706 series: vec![
707 some_option_strings(&["a", "b", "c"]),
708 some_option_strings(&["1.0", "2.0", "3.0"]),
709 ],
710 };
711 let params = Parameters {
712 title: Some(String::from("EXPLICIT")),
713 ..Parameters::default()
714 };
715
716 let rendered = render_plain(Command::Bar, &data, ¶ms);
717 assert!(
718 rendered.contains("EXPLICIT"),
719 "barplot should preserve explicit title"
720 );
721 }
722
723 #[test]
724 fn lenient_parsing_coerces_non_numeric_to_zero() {
725 let mixed = some_option_strings(&["1.5", "hello", "3.0"]);
726 assert_eq!(parse_numeric_series_lenient(&mixed), vec![1.5, 0.0, 3.0]);
727
728 let with_none: Vec<Option<String>> = vec![Some("2.0".into()), None, Some("4.0".into())];
729 assert_eq!(
730 parse_numeric_series_lenient(&with_none),
731 vec![2.0, 0.0, 4.0]
732 );
733
734 assert_eq!(
735 parse_numeric_series_lenient(&[]),
736 Vec::<f64>::new(),
737 "empty input should return empty output"
738 );
739
740 let sci = some_option_strings(&["1e3", "2.5e-1"]);
741 assert_eq!(parse_numeric_series_lenient(&sci), vec![1000.0, 0.25]);
742 }
743
744 #[test]
745 fn lenient_parsing_maps_non_finite_to_zero() {
746 let non_finite = some_option_strings(&["NaN", "inf", "-inf", "42.0"]);
747 assert_eq!(
748 parse_numeric_series_lenient(&non_finite),
749 vec![0.0, 0.0, 0.0, 42.0],
750 "NaN/Inf/-Inf should coerce to 0.0 matching Ruby's String#to_f"
751 );
752
753 let whitespace_only = some_option_strings(&[" ", ""]);
754 assert_eq!(
755 parse_numeric_series_lenient(&whitespace_only),
756 vec![0.0, 0.0],
757 "whitespace-only and empty strings should coerce to 0.0"
758 );
759 }
760
761 #[test]
762 fn infer_multi_series_xlabel_respects_existing_value_and_format() {
763 let data = Data {
764 headers: Some(some_option_strings(&["x_col", "y1", "y2"])),
765 series: vec![
766 some_option_strings(&["1.0"]),
767 some_option_strings(&["2.0"]),
768 some_option_strings(&["3.0"]),
769 ],
770 };
771
772 let mut params = Parameters {
773 xlabel: Some("EXPLICIT".into()),
774 ..Parameters::default()
775 };
776 infer_multi_series_xlabel(&data, MultiFormat::Xyy, &mut params);
777 assert_eq!(
778 params.xlabel.as_deref(),
779 Some("EXPLICIT"),
780 "explicit xlabel should not be overwritten by header"
781 );
782
783 let mut params2 = Parameters::default();
784 infer_multi_series_xlabel(&data, MultiFormat::Xyxy, &mut params2);
785 assert_eq!(
786 params2.xlabel, None,
787 "xyxy format should not set xlabel from headers"
788 );
789
790 let mut params3 = Parameters::default();
791 infer_multi_series_xlabel(&data, MultiFormat::Xyy, &mut params3);
792 assert_eq!(
793 params3.xlabel.as_deref(),
794 Some("x_col"),
795 "xyy format should set xlabel from headers[0] when xlabel is None"
796 );
797
798 assert_eq!(
799 header_at(&data, 0),
800 Some("x_col".into()),
801 "header_at should return the header at the given index"
802 );
803 assert_eq!(
804 header_at(&data, 99),
805 None,
806 "header_at should return None for out-of-bounds index"
807 );
808
809 let no_headers = Data {
810 headers: None,
811 series: vec![],
812 };
813 assert_eq!(
814 header_at(&no_headers, 0),
815 None,
816 "header_at should return None when no headers present"
817 );
818 }
819
820 #[test]
821 fn renderable_dispatch_output_renders_without_color() {
822 let data = Data {
823 headers: None,
824 series: vec![
825 some_option_strings(&["a", "b"]),
826 some_option_strings(&["1.0", "2.0"]),
827 ],
828 };
829
830 let output = dispatch(Command::Bar, &data, &Parameters::default())
831 .expect("dispatch should return a renderable output");
832 let mut buffer = Vec::new();
833 output
834 .render_with_mode(&mut buffer, ColorMode::Never, false)
835 .expect("rendering dispatched output should succeed");
836 assert!(!buffer.is_empty());
837 }
838
839 #[test]
840 fn colors_output_contains_all_named_entries_and_256_palette() {
841 let output = ColorsRenderable::render_string();
842
843 assert!(
844 output.ends_with('\n'),
845 "colors output should end with a newline"
846 );
847
848 let lines: Vec<_> = output.lines().collect();
849 assert_eq!(
850 lines.len(),
851 1,
852 "colors output should be exactly one line, got {}",
853 lines.len()
854 );
855
856 for name in &[
857 "black",
858 "red",
859 "green",
860 "yellow",
861 "blue",
862 "magenta",
863 "cyan",
864 "white",
865 "gray",
866 "light_black",
867 "light_red",
868 "light_green",
869 "light_yellow",
870 "light_blue",
871 "light_magenta",
872 "light_cyan",
873 "normal",
874 "default",
875 "bold",
876 "underline",
877 "blink",
878 "reverse",
879 "hidden",
880 "nothing",
881 ] {
882 assert!(
883 output.contains(name),
884 "colors output should contain named entry {name:?}"
885 );
886 }
887
888 assert!(
890 output.contains("\x1b[31mred\t"),
891 "red should be preceded by its ANSI escape"
892 );
893 assert!(
894 output.contains("\x1b[90mlight_black\t"),
895 "light_black should be preceded by bright-black escape"
896 );
897 assert!(
898 output.contains("nothing\t"),
899 "nothing entry should have no escape prefix"
900 );
901
902 assert!(
903 output.contains("\x1b[38;5;0m0\t"),
904 "colors output should contain 256-color palette entry 0"
905 );
906 assert!(
907 output.contains("\x1b[38;5;255m255\t"),
908 "colors output should contain 256-color palette entry 255"
909 );
910 assert!(
911 output.contains("\x1b[38;5;128m128\t"),
912 "colors output should contain 256-color palette entry 128"
913 );
914 }
915
916 #[test]
917 fn colors_output_entry_count_matches_ruby() {
918 let output = ColorsRenderable::render_string();
919
920 let bullet = "\u{25CF}";
921 let entry_count = output.matches(bullet).count();
922 assert_eq!(
923 entry_count, 280,
924 "colors output should have 280 entries (24 named + 256 palette), got {entry_count}"
925 );
926 }
927
928 #[test]
929 fn colors_dispatch_renders_identically_regardless_of_color_mode() {
930 let empty_data = Data {
931 headers: None,
932 series: vec![],
933 };
934 let output = dispatch(Command::Colors, &empty_data, &Parameters::default())
935 .expect("colors dispatch should succeed");
936
937 let mut always_buf = Vec::new();
938 output
939 .render_with_mode(&mut always_buf, ColorMode::Always, true)
940 .expect("colors always render should succeed");
941
942 let mut never_buf = Vec::new();
943 output
944 .render_with_mode(&mut never_buf, ColorMode::Never, false)
945 .expect("colors never render should succeed");
946
947 assert_eq!(
948 always_buf, never_buf,
949 "colors output should be identical regardless of color mode"
950 );
951 }
952}