Skip to main content

trustformers_debug/visualization/
mod.rs

1//! Visualization module for TrustformeRS debugging tools
2//!
3//! This module has been refactored into focused submodules to comply with the
4//! 2000-line policy. The original visualization.rs (2843 lines) has been split into:
5//!
6//! - `types` - Basic visualization types, enums, and data structures
7//! - Additional modules to be created as needed for terminal, video, etc.
8
9// `ascii_tools` and `gradient_animation` are real, fully tested renderers that
10// were never declared here, so neither compiled nor ran: 1357 lines and 42
11// tests were silently dead, and `ascii_tools`' own doc example
12// (`use trustformers_debug::visualization::ascii_tools::AsciiLossPlotter;`)
13// could not resolve. They are deliberately NOT glob re-exported: `ascii_tools`
14// defines its own `AttentionVisualizer`, which would collide with the
15// crate-root [`crate::attention_visualizer::AttentionVisualizer`].
16pub mod ascii_tools;
17pub mod gradient_animation;
18pub mod modern_plotting;
19pub mod svg_render;
20pub mod types;
21
22// Re-export main types for backward compatibility
23pub use modern_plotting::*;
24pub use types::*;
25
26use anyhow::{anyhow, Result};
27use indexmap::IndexMap;
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30
31/// A plot's source data, kept so exports and dashboards work off the real
32/// numbers rather than a re-derived guess.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum PlotSource {
36    /// A 2-D line plot.
37    Line(PlotData),
38    /// A binned histogram.
39    Histogram(HistogramData),
40    /// A 2-D matrix heatmap.
41    Heatmap(HeatmapData),
42}
43
44impl PlotSource {
45    /// The plot's title, which doubles as its registry key.
46    pub fn title(&self) -> &str {
47        match self {
48            PlotSource::Line(d) => &d.title,
49            PlotSource::Histogram(d) => &d.title,
50            PlotSource::Heatmap(d) => &d.title,
51        }
52    }
53}
54
55/// One rendered plot: its source data, the document that was actually written,
56/// and where it was written to.
57#[derive(Debug, Clone)]
58pub struct RenderedPlot {
59    /// Registry key (the plot title).
60    pub name: String,
61    /// File the rendered document was written to.
62    pub path: PathBuf,
63    /// The rendered document text (SVG, HTML or JSON depending on the config).
64    pub document: String,
65    /// The data the document was rendered from.
66    pub source: PlotSource,
67}
68
69/// Debug visualizer that renders real plot documents to disk.
70///
71/// Every `create_*` / `plot_*` method renders the supplied data into a real
72/// document (a genuine SVG/HTML/JSON file with axes, points and colour-mapped
73/// cells derived from the caller's numbers), writes it under
74/// [`VisualizationConfig::output_directory`] and returns that file's path.
75///
76/// Formats this crate cannot encode without an optional raster backend
77/// (`ImageFormat::PNG`, `PDF`, `LaTeX`, `MP4`, `GIF`, `WebM`) are rejected with
78/// a structured error naming the missing encoder — they are never written out
79/// as SVG bytes under a misleading extension.
80#[derive(Debug)]
81pub struct DebugVisualizer {
82    config: VisualizationConfig,
83    /// Plots rendered so far, in creation order, keyed by title.
84    plots: IndexMap<String, RenderedPlot>,
85}
86
87impl DebugVisualizer {
88    pub fn new(config: VisualizationConfig) -> Self {
89        Self {
90            config,
91            plots: IndexMap::new(),
92        }
93    }
94
95    pub fn with_default_config() -> Self {
96        Self::new(VisualizationConfig::default())
97    }
98
99    /// The configuration this visualizer renders with.
100    pub fn config(&self) -> &VisualizationConfig {
101        &self.config
102    }
103
104    /// File extension for the configured output format, or a structured error
105    /// naming why this crate cannot produce that format.
106    fn extension_for(format: &ImageFormat) -> Result<&'static str> {
107        match format {
108            ImageFormat::SVG => Ok("svg"),
109            ImageFormat::HTML => Ok("html"),
110            ImageFormat::JSON => Ok("json"),
111            ImageFormat::PNG => Err(anyhow!(
112                "ImageFormat::PNG cannot be encoded: trustformers-debug ships no raster \
113                 encoder in its default (Pure-Rust) feature set. Configure \
114                 VisualizationConfig::image_format = ImageFormat::SVG, or use the \
115                 `visual`/`image` cargo features for the plotters raster backends."
116            )),
117            ImageFormat::PDF => Err(anyhow!(
118                "ImageFormat::PDF cannot be encoded: no PDF writer is linked into \
119                 trustformers-debug. Configure ImageFormat::SVG instead."
120            )),
121            ImageFormat::LaTeX => Err(anyhow!(
122                "ImageFormat::LaTeX is not implemented for plots: no TikZ/PGFPlots emitter \
123                 exists in trustformers-debug. Configure ImageFormat::SVG instead."
124            )),
125            ImageFormat::MP4 | ImageFormat::WebM => Err(anyhow!(
126                "ImageFormat::{format:?} is a video container and there is no muxer or \
127                 frame encoder in trustformers-debug (the `video` cargo feature was removed \
128                 because nothing implemented it). Configure ImageFormat::SVG instead."
129            )),
130            ImageFormat::GIF => Err(anyhow!(
131                "ImageFormat::GIF cannot be encoded from a static plot: the animated-GIF \
132                 path lives behind the optional `gif` cargo feature and takes a frame \
133                 sequence, not a single plot. Configure ImageFormat::SVG instead."
134            )),
135        }
136    }
137
138    /// Render `source` into the configured format and write it to disk.
139    ///
140    /// Returns the path of the file that was actually written.
141    fn render_and_store(&mut self, source: PlotSource) -> Result<String> {
142        let ext = Self::extension_for(&self.config.image_format)?;
143
144        let svg = match &source {
145            PlotSource::Line(d) => svg_render::line_plot_svg(d, &self.config),
146            PlotSource::Histogram(d) => svg_render::histogram_svg(d, &self.config),
147            PlotSource::Heatmap(d) => svg_render::heatmap_svg(d, &self.config),
148        };
149
150        let title = source.title().to_string();
151        let document = match self.config.image_format {
152            ImageFormat::SVG => svg,
153            ImageFormat::HTML => format!(
154                "<!doctype html>\n<html><head><meta charset=\"utf-8\">\
155                 <title>{}</title></head><body>\n{}</body></html>\n",
156                svg_render::escape_xml(&title),
157                svg
158            ),
159            // JSON exports the source data itself, which is the only honest
160            // JSON representation of a plot.
161            ImageFormat::JSON => serde_json::to_string_pretty(&source)?,
162            // Every remaining variant already returned an error from
163            // `extension_for` above.
164            _ => unreachable!("extension_for rejects every non-text format"),
165        };
166
167        let dir = Path::new(&self.config.output_directory);
168        std::fs::create_dir_all(dir)?;
169        let path = dir.join(format!("{}.{}", svg_render::slugify(&title), ext));
170        std::fs::write(&path, &document)?;
171
172        let rendered = RenderedPlot {
173            name: title.clone(),
174            path: path.clone(),
175            document,
176            source,
177        };
178        self.plots.insert(title, rendered);
179        Ok(path.to_string_lossy().to_string())
180    }
181
182    /// Render a line plot and return the path of the file written.
183    pub fn create_line_plot(&mut self, data: &PlotData) -> Result<String> {
184        self.render_and_store(PlotSource::Line(data.clone()))
185    }
186
187    /// Render a heatmap and return the path of the file written.
188    pub fn create_heatmap(&mut self, data: &HeatmapData) -> Result<String> {
189        self.render_and_store(PlotSource::Heatmap(data.clone()))
190    }
191
192    /// Render a histogram and return the path of the file written.
193    pub fn create_histogram(&mut self, data: &HistogramData) -> Result<String> {
194        self.render_and_store(PlotSource::Histogram(data.clone()))
195    }
196
197    /// Plot tensor distribution
198    pub fn plot_tensor_distribution(
199        &mut self,
200        name: &str,
201        values: &[f64],
202        bins: usize,
203    ) -> Result<String> {
204        let data = HistogramData {
205            values: values.to_vec(),
206            bins,
207            title: format!("{} Distribution", name),
208            x_label: "Value".to_string(),
209            y_label: "Frequency".to_string(),
210            density: false,
211        };
212        self.create_histogram(&data)
213    }
214
215    /// Plot training metrics
216    pub fn plot_training_metrics(
217        &mut self,
218        steps: &[f64],
219        losses: &[f64],
220        accuracies: Option<&[f64]>,
221    ) -> Result<String> {
222        let mut plot_data = PlotData {
223            x_values: steps.to_vec(),
224            y_values: losses.to_vec(),
225            labels: vec!["Loss".to_string()],
226            title: "Training Metrics".to_string(),
227            x_label: "Steps".to_string(),
228            y_label: "Value".to_string(),
229        };
230
231        if let Some(acc) = accuracies {
232            plot_data.y_values.extend_from_slice(acc);
233            plot_data.labels.push("Accuracy".to_string());
234        }
235
236        self.create_line_plot(&plot_data)
237    }
238
239    /// Plot gradient flow
240    pub fn plot_gradient_flow(
241        &mut self,
242        layer_name: &str,
243        steps: &[f64],
244        gradient_norms: &[f64],
245    ) -> Result<String> {
246        let data = PlotData {
247            x_values: steps.to_vec(),
248            y_values: gradient_norms.to_vec(),
249            labels: vec![format!("{} Gradient Flow", layer_name)],
250            title: format!("Gradient Flow - {}", layer_name),
251            x_label: "Steps".to_string(),
252            y_label: "Gradient Norm".to_string(),
253        };
254        self.create_line_plot(&data)
255    }
256
257    /// Plot tensor heatmap
258    pub fn plot_tensor_heatmap(&mut self, name: &str, values: &[Vec<f64>]) -> Result<String> {
259        let data = HeatmapData {
260            values: values.to_vec(),
261            x_labels: (0..values.first().map_or(0, |row| row.len()))
262                .map(|i| i.to_string())
263                .collect(),
264            y_labels: (0..values.len()).map(|i| i.to_string()).collect(),
265            title: format!("{} Heatmap", name),
266            color_bar_label: "Value".to_string(),
267        };
268        self.create_heatmap(&data)
269    }
270
271    /// Plot activation patterns
272    pub fn plot_activation_patterns(
273        &mut self,
274        layer_name: &str,
275        inputs: &[f64],
276        outputs: &[f64],
277    ) -> Result<String> {
278        let data = PlotData {
279            x_values: inputs.to_vec(),
280            y_values: outputs.to_vec(),
281            labels: vec![format!("{} Activation", layer_name)],
282            title: format!("Activation Pattern - {}", layer_name),
283            x_label: "Input".to_string(),
284            y_label: "Output".to_string(),
285        };
286        self.create_line_plot(&data)
287    }
288
289    /// Names of the plots this visualizer has actually rendered, in creation
290    /// order.
291    ///
292    /// Empty until something has been plotted — it is not a catalogue of what
293    /// the visualizer *could* draw.
294    pub fn get_plot_names(&self) -> Vec<String> {
295        self.plots.keys().cloned().collect()
296    }
297
298    /// Look up a rendered plot by title.
299    pub fn get_plot(&self, name: &str) -> Option<&RenderedPlot> {
300        self.plots.get(name)
301    }
302
303    /// Build a dashboard page embedding the named plots.
304    ///
305    /// SVG/HTML renderings are inlined verbatim; a JSON rendering is linked by
306    /// path (there is nothing to inline). Unknown names are rejected with a
307    /// structured error listing what has actually been rendered, rather than
308    /// emitting an empty card that looks like a plot.
309    pub fn create_dashboard(&mut self, plot_names: &[String]) -> Result<String> {
310        let unknown: Vec<&str> = plot_names
311            .iter()
312            .map(String::as_str)
313            .filter(|n| !self.plots.contains_key(*n))
314            .collect();
315        if !unknown.is_empty() {
316            return Err(anyhow!(
317                "cannot build a dashboard for plots that were never rendered: {:?}. \
318                 Rendered plots are: {:?}",
319                unknown,
320                self.get_plot_names()
321            ));
322        }
323
324        let dashboard_path = Path::new(&self.config.output_directory).join("dashboard.html");
325        std::fs::create_dir_all(&self.config.output_directory)?;
326
327        let mut html = String::from(
328            "<!doctype html>\n<html><head><meta charset=\"utf-8\">\
329             <title>Debug Dashboard</title></head><body>\n",
330        );
331        html.push_str("<h1>TrustformeRS Debug Dashboard</h1>\n");
332
333        for plot_name in plot_names {
334            let plot = self.plots.get(plot_name).ok_or_else(|| {
335                anyhow!("plot {plot_name:?} disappeared from the registry mid-render")
336            })?;
337            html.push_str(&format!(
338                "<section><h2>{}</h2>\n",
339                svg_render::escape_xml(plot_name)
340            ));
341            match self.config.image_format {
342                ImageFormat::SVG => html.push_str(&plot.document),
343                ImageFormat::HTML => {
344                    // Inline just the <svg> element, not a nested document.
345                    match (plot.document.find("<svg"), plot.document.rfind("</svg>")) {
346                        (Some(a), Some(b)) => html.push_str(&plot.document[a..b + 6]),
347                        _ => html.push_str(&format!(
348                            "<p><a href=\"{}\">{}</a></p>",
349                            svg_render::escape_xml(&plot.path.to_string_lossy()),
350                            svg_render::escape_xml(&plot.path.to_string_lossy())
351                        )),
352                    }
353                },
354                _ => html.push_str(&format!(
355                    "<p><a href=\"{}\">{}</a></p>",
356                    svg_render::escape_xml(&plot.path.to_string_lossy()),
357                    svg_render::escape_xml(&plot.path.to_string_lossy())
358                )),
359            }
360            html.push_str("\n</section>\n");
361        }
362
363        html.push_str("</body></html>\n");
364        std::fs::write(&dashboard_path, html)?;
365
366        Ok(dashboard_path.to_string_lossy().to_string())
367    }
368
369    /// Export a rendered plot's **source data** as JSON to `export_path`.
370    ///
371    /// Errors when `plot_name` has not been rendered — the previous version
372    /// wrote the literal string `"Plot data for: <name>"`, which contained no
373    /// plot data at all and succeeded for names that never existed.
374    pub fn export_plot_data(&self, plot_name: &str, export_path: &Path) -> Result<()> {
375        let plot = self.plots.get(plot_name).ok_or_else(|| {
376            anyhow!(
377                "no plot named {plot_name:?} has been rendered; rendered plots are: {:?}",
378                self.get_plot_names()
379            )
380        })?;
381        if let Some(parent) = export_path.parent() {
382            if !parent.as_os_str().is_empty() {
383                std::fs::create_dir_all(parent)?;
384            }
385        }
386        std::fs::write(export_path, serde_json::to_string_pretty(&plot.source)?)?;
387        Ok(())
388    }
389
390    /// Write the most recently rendered plot document to
391    /// `output_directory/filename`.
392    ///
393    /// Errors when nothing has been rendered yet, instead of writing the
394    /// literal placeholder text the previous implementation emitted.
395    pub fn save_to_file(&self, filename: &str) -> Result<()> {
396        let (_, plot) = self.plots.last().ok_or_else(|| {
397            anyhow!(
398                "save_to_file({filename:?}): nothing has been rendered yet, so there is no \
399                 visualization to save"
400            )
401        })?;
402        std::fs::create_dir_all(&self.config.output_directory)?;
403        let output_path = Path::new(&self.config.output_directory).join(filename);
404        std::fs::write(output_path, &plot.document)?;
405        Ok(())
406    }
407}
408
409/// Simple terminal-based visualizer
410pub struct TerminalVisualizer;
411
412impl TerminalVisualizer {
413    pub fn new() -> Self {
414        Self
415    }
416
417    /// Display simple text-based histogram in terminal
418    pub fn display_histogram(&self, data: &HistogramData) -> Result<()> {
419        println!("Terminal Histogram: {}", data.title);
420        println!("Data points: {}", data.values.len());
421        if data.values.is_empty() {
422            return Ok(());
423        }
424        let bins = if data.bins == 0 { 10 } else { data.bins };
425        let rendered = self.ascii_histogram(&data.values, bins);
426        if !data.x_label.is_empty() || !data.y_label.is_empty() {
427            println!("{} vs {}", data.y_label, data.x_label);
428        }
429        print!("{}", rendered);
430        Ok(())
431    }
432
433    /// Display simple text-based statistics
434    pub fn display_statistics(&self, label: &str, values: &[f64]) -> Result<()> {
435        if values.is_empty() {
436            println!("{}: No data", label);
437            return Ok(());
438        }
439
440        let mean = values.iter().sum::<f64>() / values.len() as f64;
441        let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
442        let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
443
444        println!(
445            "{}: mean={:.3}, min={:.3}, max={:.3}",
446            label, mean, min, max
447        );
448        Ok(())
449    }
450
451    /// ASCII histogram display
452    pub fn ascii_histogram(&self, values: &[f64], bins: usize) -> String {
453        if values.is_empty() {
454            return "No data for histogram".to_string();
455        }
456
457        let min_val = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
458        let max_val = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
459
460        if (max_val - min_val).abs() < f64::EPSILON {
461            return format!("All values are {:.3}", min_val);
462        }
463
464        let mut histogram = vec![0; bins];
465        let bin_width = (max_val - min_val) / bins as f64;
466
467        for &value in values {
468            let bin_index = ((value - min_val) / bin_width).floor() as usize;
469            let bin_index = bin_index.min(bins - 1);
470            histogram[bin_index] += 1;
471        }
472
473        let max_count = histogram.iter().max().unwrap_or(&0);
474        let scale = if *max_count > 0 { 40.0 / *max_count as f64 } else { 1.0 };
475
476        let mut result = String::new();
477        for (i, &count) in histogram.iter().enumerate() {
478            let bin_start = min_val + i as f64 * bin_width;
479            let bin_end = bin_start + bin_width;
480            let bar_length = (count as f64 * scale) as usize;
481            let bar = "█".repeat(bar_length);
482            result.push_str(&format!(
483                "[{:.2}-{:.2}): {} ({})\n",
484                bin_start, bin_end, bar, count
485            ));
486        }
487
488        result
489    }
490
491    /// ASCII line plot display
492    pub fn ascii_line_plot(&self, x_values: &[f64], y_values: &[f64], title: &str) -> String {
493        if x_values.is_empty() || y_values.is_empty() || x_values.len() != y_values.len() {
494            return "Invalid data for line plot".to_string();
495        }
496
497        let min_y = y_values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
498        let max_y = y_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
499
500        let mut result = format!("{}\n", title);
501        result.push_str("═".repeat(title.len()).as_str());
502        result.push('\n');
503
504        if (max_y - min_y).abs() < f64::EPSILON {
505            result.push_str(&format!("Constant value: {:.3}\n", min_y));
506            return result;
507        }
508
509        let height = 20;
510        let width = x_values.len().min(80);
511
512        // Sample data if too many points
513        let step = if x_values.len() > width { x_values.len() / width } else { 1 };
514
515        for row in (0..height).rev() {
516            let y_threshold = min_y + (max_y - min_y) * row as f64 / (height - 1) as f64;
517            let mut line = String::new();
518
519            for i in (0..x_values.len()).step_by(step).take(width) {
520                if y_values[i] >= y_threshold {
521                    line.push('*');
522                } else {
523                    line.push(' ');
524                }
525            }
526            result.push_str(&format!("{:8.2} |{}\n", y_threshold, line));
527        }
528
529        result.push_str(&format!("{:8} +{}\n", "", "─".repeat(width)));
530        result
531    }
532}
533
534impl Default for TerminalVisualizer {
535    fn default() -> Self {
536        Self::new()
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    /// Unique scratch directory under the platform temp dir (never a hardcoded path).
545    fn scratch(tag: &str) -> String {
546        let dir = std::env::temp_dir().join(format!(
547            "tfdbg_viz_{}_{}_{}",
548            tag,
549            std::process::id(),
550            std::time::SystemTime::now()
551                .duration_since(std::time::UNIX_EPOCH)
552                .map(|d| d.as_nanos())
553                .unwrap_or(0)
554        ));
555        dir.to_string_lossy().to_string()
556    }
557
558    fn viz(tag: &str) -> DebugVisualizer {
559        DebugVisualizer::new(VisualizationConfig {
560            output_directory: scratch(tag),
561            ..Default::default()
562        })
563    }
564
565    fn sample_line() -> PlotData {
566        PlotData {
567            x_values: vec![0.0, 1.0, 2.0, 3.0],
568            y_values: vec![1.0, 4.0, 9.0, 16.0],
569            labels: vec!["squares".to_string()],
570            title: "Squares".to_string(),
571            x_label: "n".to_string(),
572            y_label: "n^2".to_string(),
573        }
574    }
575
576    #[test]
577    fn create_line_plot_writes_a_real_svg_document() {
578        let mut v = viz("line");
579        let path = v.create_line_plot(&sample_line()).expect("render must succeed");
580        let written = std::fs::read_to_string(&path).expect("the returned path must exist");
581        // The old implementation returned "Line plot 'Squares' created successfully"
582        // and wrote nothing at all.
583        assert!(!path.contains("created successfully"));
584        assert!(
585            written.starts_with("<svg"),
586            "must be a real SVG: {written:.80}"
587        );
588        assert!(
589            written.contains("<polyline"),
590            "must contain the real data polyline"
591        );
592        assert!(
593            written.contains(">squares<"),
594            "must carry the real series label"
595        );
596        let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
597    }
598
599    #[test]
600    fn get_plot_names_reports_only_what_was_really_rendered() {
601        let mut v = viz("names");
602        // Previously this returned four invented names "for demonstration"
603        // before anything had been plotted.
604        assert!(v.get_plot_names().is_empty(), "nothing rendered yet");
605        v.create_line_plot(&sample_line()).expect("render");
606        v.plot_tensor_distribution("weights", &[1.0, 2.0, 3.0, 4.0], 4).expect("render");
607        assert_eq!(
608            v.get_plot_names(),
609            vec!["Squares".to_string(), "weights Distribution".to_string()]
610        );
611        let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
612    }
613
614    #[test]
615    fn export_plot_data_exports_the_real_source_data() {
616        let mut v = viz("export");
617        v.create_line_plot(&sample_line()).expect("render");
618        let out = std::path::PathBuf::from(v.config().output_directory.clone()).join("d.json");
619        v.export_plot_data("Squares", &out).expect("export must succeed");
620        let text = std::fs::read_to_string(&out).expect("export file");
621        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
622        assert_eq!(parsed["kind"], "line");
623        assert_eq!(
624            parsed["y_values"][3], 16.0,
625            "the real y values must round-trip"
626        );
627        let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
628    }
629
630    #[test]
631    fn export_plot_data_refuses_a_plot_that_was_never_rendered() {
632        let v = viz("export_missing");
633        let out = std::env::temp_dir().join("tfdbg_never_written.json");
634        let err = v.export_plot_data("nope", &out).expect_err("must not fabricate an export");
635        let msg = err.to_string();
636        assert!(
637            msg.contains("no plot named"),
638            "structured error names the problem: {msg}"
639        );
640        assert!(
641            !out.exists(),
642            "must not write a file for a plot that does not exist"
643        );
644    }
645
646    #[test]
647    fn save_to_file_refuses_before_anything_is_rendered() {
648        let v = viz("save_empty");
649        let err = v.save_to_file("x.svg").expect_err("must not write placeholder content");
650        assert!(err.to_string().contains("nothing has been rendered"));
651    }
652
653    #[test]
654    fn save_to_file_writes_the_real_rendered_document() {
655        let mut v = viz("save");
656        v.create_line_plot(&sample_line()).expect("render");
657        v.save_to_file("copy.svg").expect("save must succeed");
658        let text = std::fs::read_to_string(
659            std::path::PathBuf::from(v.config().output_directory.clone()).join("copy.svg"),
660        )
661        .expect("saved file");
662        assert!(
663            text.contains("<polyline"),
664            "the saved bytes are the real rendering"
665        );
666        assert!(!text.contains("placeholder visualization content"));
667        let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
668    }
669
670    #[test]
671    fn unencodable_formats_return_a_structured_error_not_mislabelled_bytes() {
672        for (format, needle) in [
673            (ImageFormat::PNG, "no raster encoder"),
674            (ImageFormat::PDF, "no PDF writer"),
675            (ImageFormat::MP4, "video container"),
676            (ImageFormat::GIF, "animated-GIF"),
677            (ImageFormat::LaTeX, "not implemented"),
678        ] {
679            let dir = scratch("fmt");
680            let mut v = DebugVisualizer::new(VisualizationConfig {
681                output_directory: dir.clone(),
682                image_format: format.clone(),
683                ..Default::default()
684            });
685            match v.create_line_plot(&sample_line()) {
686                Ok(p) => panic!("{format:?} must be refused, but it wrote {p}"),
687                Err(e) => assert!(
688                    e.to_string().contains(needle),
689                    "{format:?} error must name the missing encoder ({needle}): {e}"
690                ),
691            }
692            assert!(
693                !std::path::Path::new(&dir).exists(),
694                "{format:?}: nothing may be written for a refused format"
695            );
696        }
697    }
698
699    #[test]
700    fn png_is_refused_and_writes_nothing() {
701        let dir = scratch("png");
702        let mut v = DebugVisualizer::new(VisualizationConfig {
703            output_directory: dir.clone(),
704            image_format: ImageFormat::PNG,
705            ..Default::default()
706        });
707        let err = v.create_line_plot(&sample_line()).expect_err("PNG must be refused");
708        assert!(err.to_string().contains("no raster encoder"), "{err}");
709        assert!(
710            !std::path::Path::new(&dir).exists(),
711            "nothing may be written for a refused format"
712        );
713    }
714
715    #[test]
716    fn create_dashboard_embeds_real_plots_and_refuses_unknown_names() {
717        let mut v = viz("dash");
718        v.create_line_plot(&sample_line()).expect("render");
719        let err = v
720            .create_dashboard(&["Squares".to_string(), "ghost".to_string()])
721            .expect_err("unknown plot names must be refused");
722        assert!(err.to_string().contains("never rendered"), "{err}");
723
724        let path = v.create_dashboard(&v.get_plot_names()).expect("dashboard must build");
725        let html = std::fs::read_to_string(&path).expect("dashboard file");
726        assert!(
727            html.contains("<polyline"),
728            "the dashboard inlines the real SVG"
729        );
730        assert!(!html.contains("<p>Plot: "), "no fake plot cards");
731        let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
732    }
733
734    #[test]
735    fn json_format_writes_the_real_source_data() {
736        let dir = scratch("json");
737        let mut v = DebugVisualizer::new(VisualizationConfig {
738            output_directory: dir.clone(),
739            image_format: ImageFormat::JSON,
740            ..Default::default()
741        });
742        let path = v
743            .create_histogram(&HistogramData {
744                values: vec![1.0, 2.0, 3.0],
745                bins: 3,
746                title: "H".to_string(),
747                x_label: String::new(),
748                y_label: String::new(),
749                density: false,
750            })
751            .expect("json render");
752        assert!(
753            path.ends_with(".json"),
754            "extension must match the real content: {path}"
755        );
756        let parsed: serde_json::Value =
757            serde_json::from_str(&std::fs::read_to_string(&path).expect("file")).expect("json");
758        assert_eq!(parsed["kind"], "histogram");
759        assert_eq!(parsed["values"][2], 3.0);
760        let _ = std::fs::remove_dir_all(dir);
761    }
762
763    #[test]
764    fn test_display_histogram_empty_returns_ok() {
765        let viz = TerminalVisualizer::new();
766        let data = HistogramData {
767            values: vec![],
768            bins: 10,
769            title: "empty".to_string(),
770            x_label: String::new(),
771            y_label: String::new(),
772            density: false,
773        };
774        assert!(viz.display_histogram(&data).is_ok());
775    }
776
777    #[test]
778    fn test_display_histogram_with_values_returns_ok() {
779        let viz = TerminalVisualizer::new();
780        let data = HistogramData {
781            values: (0..50).map(|i| i as f64).collect(),
782            bins: 5,
783            title: "ramp".to_string(),
784            x_label: "value".to_string(),
785            y_label: "count".to_string(),
786            density: false,
787        };
788        assert!(viz.display_histogram(&data).is_ok());
789    }
790
791    #[test]
792    fn test_display_histogram_zero_bins_falls_back_to_default() {
793        let viz = TerminalVisualizer::new();
794        let data = HistogramData {
795            values: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
796            bins: 0,
797            title: "fallback".to_string(),
798            x_label: String::new(),
799            y_label: String::new(),
800            density: false,
801        };
802        // Should not panic with zero bins.
803        assert!(viz.display_histogram(&data).is_ok());
804    }
805}