|
| 1 | +use super::Exporter; |
| 2 | +use crate::benchmark::benchmark_result::BenchmarkResult; |
| 3 | +use crate::benchmark::relative_speed; |
| 4 | +use crate::options::SortOrder; |
| 5 | +use crate::util::units::Unit; |
| 6 | + |
| 7 | +use anyhow::Result; |
| 8 | +use serde_json; |
| 9 | + |
| 10 | +/// HTML exporter for benchmark results. |
| 11 | +/// |
| 12 | +/// Generates a standalone HTML file with interactive visualizations using Plotly.js |
| 13 | +#[derive(Default)] |
| 14 | +pub struct HtmlExporter {} |
| 15 | + |
| 16 | +impl Exporter for HtmlExporter { |
| 17 | + fn serialize( |
| 18 | + &self, |
| 19 | + results: &[BenchmarkResult], |
| 20 | + unit: Option<Unit>, |
| 21 | + sort_order: SortOrder, |
| 22 | + ) -> Result<Vec<u8>> { |
| 23 | + // Include static assets |
| 24 | + let template = include_str!("html_template.html"); |
| 25 | + let css = include_str!("html_styles.css"); |
| 26 | + let js = include_str!("html_renderer.js"); |
| 27 | + |
| 28 | + // Build the HTML document with embedded resources |
| 29 | + let mut html = template.to_string(); |
| 30 | + html = html.replace("/* CSS will be embedded here */", css); |
| 31 | + html = html.replace("// JS will be embedded here", js); |
| 32 | + |
| 33 | + // Determine the appropriate unit if not specified |
| 34 | + let unit = unit.unwrap_or_else(|| determine_unit_from_results(results)); |
| 35 | + |
| 36 | + // Compute relative speeds and sort results |
| 37 | + let entries = relative_speed::compute(results, sort_order); |
| 38 | + |
| 39 | + // Get the reference command if there is one |
| 40 | + let reference_command = entries |
| 41 | + .iter() |
| 42 | + .find(|e| e.is_reference) |
| 43 | + .map_or("", |e| &e.result.command); |
| 44 | + |
| 45 | + // Serialize benchmark data to JSON for JavaScript consumption |
| 46 | + let json_data = serde_json::to_string( |
| 47 | + &entries |
| 48 | + .iter() |
| 49 | + .map(|entry| &entry.result) |
| 50 | + .collect::<Vec<_>>(), |
| 51 | + )?; |
| 52 | + |
| 53 | + // Replace placeholder with benchmark data and unit information |
| 54 | + let data_script = format!( |
| 55 | + "const benchmarkData = {};\n\ |
| 56 | + const unitShortName = \"{}\";\n\ |
| 57 | + const unitName = \"{}\";\n\ |
| 58 | + const referenceCommand = \"{}\";\n\ |
| 59 | + const unitFactor = {};", |
| 60 | + json_data, |
| 61 | + get_unit_short_name(unit), |
| 62 | + get_unit_name(unit), |
| 63 | + reference_command, |
| 64 | + get_unit_factor(unit) |
| 65 | + ); |
| 66 | + |
| 67 | + html = html.replace("<!-- DATA_PLACEHOLDER -->", &data_script); |
| 68 | + |
| 69 | + Ok(html.into_bytes()) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +/// Returns the full name of a time unit |
| 74 | +fn get_unit_name(unit: Unit) -> &'static str { |
| 75 | + match unit { |
| 76 | + Unit::Second => "second", |
| 77 | + Unit::MilliSecond => "millisecond", |
| 78 | + Unit::MicroSecond => "microsecond", |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +/// Returns the abbreviated symbol for a time unit |
| 83 | +fn get_unit_short_name(unit: Unit) -> &'static str { |
| 84 | + match unit { |
| 85 | + Unit::Second => "s", |
| 86 | + Unit::MilliSecond => "ms", |
| 87 | + Unit::MicroSecond => "μs", |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +/// Returns the conversion factor from seconds to the specified unit |
| 92 | +fn get_unit_factor(unit: Unit) -> f64 { |
| 93 | + match unit { |
| 94 | + Unit::Second => 1.0, |
| 95 | + Unit::MilliSecond => 1000.0, |
| 96 | + Unit::MicroSecond => 1000000.0, |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/// Automatically determines the most appropriate time unit based on benchmark results |
| 101 | +fn determine_unit_from_results(results: &[BenchmarkResult]) -> Unit { |
| 102 | + results |
| 103 | + .first() |
| 104 | + .map(|first_result| { |
| 105 | + // Choose unit based on the magnitude of the mean time |
| 106 | + let mean = first_result.mean; |
| 107 | + if mean < 0.001 { |
| 108 | + Unit::MicroSecond |
| 109 | + } else if mean < 1.0 { |
| 110 | + Unit::MilliSecond |
| 111 | + } else { |
| 112 | + Unit::Second |
| 113 | + } |
| 114 | + }) |
| 115 | + .unwrap_or(Unit::Second) // Default to seconds if no results |
| 116 | +} |
| 117 | + |
| 118 | +#[cfg(test)] |
| 119 | +mod tests { |
| 120 | + use super::*; |
| 121 | + use std::collections::BTreeMap; |
| 122 | + |
| 123 | + #[test] |
| 124 | + fn test_html_export() { |
| 125 | + // Create sample benchmark results |
| 126 | + let results = vec![ |
| 127 | + create_test_benchmark("test command 1", 1.5, None), |
| 128 | + create_test_benchmark_with_param("test command 2", 2.5, "size", "10"), |
| 129 | + ]; |
| 130 | + |
| 131 | + // Create HTML exporter |
| 132 | + let exporter = HtmlExporter::default(); |
| 133 | + |
| 134 | + // Test with seconds unit |
| 135 | + let html = export_and_get_html(&exporter, &results, Unit::Second); |
| 136 | + |
| 137 | + // Verify HTML structure and content |
| 138 | + assert_html_structure(&html); |
| 139 | + assert_contains_benchmark_data(&html, &results); |
| 140 | + assert_unit_information(&html, "s", "second", "1"); |
| 141 | + |
| 142 | + // Test with milliseconds unit |
| 143 | + let html = export_and_get_html(&exporter, &results, Unit::MilliSecond); |
| 144 | + assert_unit_information(&html, "ms", "millisecond", "1000"); |
| 145 | + |
| 146 | + // Test empty results |
| 147 | + let empty_results: Vec<BenchmarkResult> = vec![]; |
| 148 | + let html = export_and_get_html(&exporter, &empty_results, Unit::Second); |
| 149 | + assert!(html.contains("const benchmarkData = []")); |
| 150 | + } |
| 151 | + |
| 152 | + /// Helper function to create a test benchmark result |
| 153 | + fn create_test_benchmark( |
| 154 | + command: &str, |
| 155 | + mean: f64, |
| 156 | + parameters: Option<BTreeMap<String, String>>, |
| 157 | + ) -> BenchmarkResult { |
| 158 | + BenchmarkResult { |
| 159 | + command: command.to_string(), |
| 160 | + command_with_unused_parameters: command.to_string(), |
| 161 | + mean, |
| 162 | + stddev: Some(mean * 0.1), |
| 163 | + median: mean * 0.99, |
| 164 | + min: mean * 0.8, |
| 165 | + max: mean * 1.2, |
| 166 | + user: mean * 0.9, |
| 167 | + system: mean * 0.1, |
| 168 | + memory_usage_byte: None, |
| 169 | + times: Some(vec![mean * 0.8, mean * 0.9, mean, mean * 1.1, mean * 1.2]), |
| 170 | + exit_codes: vec![Some(0); 5], |
| 171 | + parameters: parameters.unwrap_or_default(), |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + /// Helper function to create a test benchmark with a parameter |
| 176 | + fn create_test_benchmark_with_param( |
| 177 | + command: &str, |
| 178 | + mean: f64, |
| 179 | + param_name: &str, |
| 180 | + param_value: &str, |
| 181 | + ) -> BenchmarkResult { |
| 182 | + let mut params = BTreeMap::new(); |
| 183 | + params.insert(param_name.to_string(), param_value.to_string()); |
| 184 | + create_test_benchmark(command, mean, Some(params)) |
| 185 | + } |
| 186 | + |
| 187 | + /// Helper function to export benchmark results and get HTML |
| 188 | + fn export_and_get_html( |
| 189 | + exporter: &HtmlExporter, |
| 190 | + results: &[BenchmarkResult], |
| 191 | + unit: Unit, |
| 192 | + ) -> String { |
| 193 | + let html_bytes = exporter |
| 194 | + .serialize(results, Some(unit), SortOrder::MeanTime) |
| 195 | + .expect("HTML export failed"); |
| 196 | + String::from_utf8(html_bytes).expect("HTML is not valid UTF-8") |
| 197 | + } |
| 198 | + |
| 199 | + /// Assert that the HTML has the expected structure |
| 200 | + fn assert_html_structure(html: &str) { |
| 201 | + assert!(html.contains("<!DOCTYPE html>")); |
| 202 | + assert!(html.contains("<html lang=\"en\">")); |
| 203 | + assert!(html.contains("<title>Hyperfine Benchmark Results</title>")); |
| 204 | + assert!(html.contains("<div class=\"container\">")); |
| 205 | + assert!(html.contains("function renderSummaryTable()")); |
| 206 | + assert!(html.contains("function renderBoxplot()")); |
| 207 | + assert!(html.contains("font-family: Arial, sans-serif")); |
| 208 | + } |
| 209 | + |
| 210 | + /// Assert that the HTML contains the benchmark data |
| 211 | + fn assert_contains_benchmark_data(html: &str, results: &[BenchmarkResult]) { |
| 212 | + assert!(html.contains("const benchmarkData =")); |
| 213 | + for result in results { |
| 214 | + assert!(html.contains(&result.command)); |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + /// Assert unit information in the HTML |
| 219 | + fn assert_unit_information(html: &str, short_name: &str, name: &str, factor: &str) { |
| 220 | + assert!(html.contains(&format!("const unitShortName = \"{}\"", short_name))); |
| 221 | + assert!(html.contains(&format!("const unitName = \"{}\"", name))); |
| 222 | + assert!(html.contains(&format!("const unitFactor = {}", factor))); |
| 223 | + } |
| 224 | +} |
0 commit comments