Skip to main content

nd_300/speedtest/
display.rs

1use crate::config::BoxChars;
2use crate::render::table::ReportBuilder;
3use crate::VERSION;
4
5use indicatif::{ProgressBar, ProgressStyle};
6use owo_colors::OwoColorize;
7
8use super::{format_bytes, format_mbps, SpeedTestResult};
9
10/// Manages live CLI progress display for the SpeedQX binary.
11pub struct SpeedQXDisplay {
12    use_ascii: bool,
13    use_colors: bool,
14    json_mode: bool,
15}
16
17impl SpeedQXDisplay {
18    pub fn new(use_ascii: bool, use_colors: bool, json_mode: bool) -> Self {
19        Self {
20            use_ascii,
21            use_colors,
22            json_mode,
23        }
24    }
25
26    /// Print the SpeedQX header banner.
27    pub fn print_header(&self) {
28        if self.json_mode {
29            return;
30        }
31        println!();
32        if self.use_colors {
33            println!(
34                "  {} - Internet Speed Test",
35                format!("SpeedQX v{}", VERSION).cyan().bold()
36            );
37            println!("  {}", "QubeTX Developer Tools".dimmed());
38        } else {
39            println!("  SpeedQX v{} - Internet Speed Test", VERSION);
40            println!("  QubeTX Developer Tools");
41        }
42        println!();
43    }
44
45    /// Create an indicatif spinner for a phase step.
46    pub fn create_spinner(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
47        if self.json_mode {
48            return ProgressBar::hidden();
49        }
50        let pb = ProgressBar::new_spinner();
51        let template = format!("  [{{spinner}}] [{}/{}] {{msg}}", step, total);
52        pb.set_style(
53            ProgressStyle::default_spinner()
54                .template(&template)
55                .unwrap_or_else(|_| ProgressStyle::default_spinner()),
56        );
57        pb.set_message(msg.to_string());
58        pb.enable_steady_tick(std::time::Duration::from_millis(80));
59        pb
60    }
61
62    /// Create an indicatif progress bar for download/upload phases.
63    pub fn create_progress_bar(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
64        if self.json_mode {
65            return ProgressBar::hidden();
66        }
67        let pb = ProgressBar::new(100);
68        let template = format!("  [{{bar:20.cyan/dim}}] [{}/{}] {{msg}}", step, total);
69        pb.set_style(
70            ProgressStyle::default_bar()
71                .template(&template)
72                .unwrap_or_else(|_| ProgressStyle::default_bar())
73                .progress_chars("##-"),
74        );
75        pb.set_message(msg.to_string());
76        pb
77    }
78
79    /// Print a persistent completion line for a finished step.
80    pub fn finish_step(&self, step: u32, total: u32, msg: &str) {
81        if self.json_mode {
82            return;
83        }
84        let check = if self.use_ascii { "[OK]" } else { "\u{2713}" };
85        if self.use_colors {
86            println!("  [{}/{}] {} {}", step, total, check.green(), msg,);
87        } else {
88            println!("  [{}/{}] {} {}", step, total, check, msg);
89        }
90    }
91}
92
93/// Render the final results table for SpeedQX output.
94pub fn render_results(result: &SpeedTestResult, use_ascii: bool, use_colors: bool) -> String {
95    let chars = if use_ascii {
96        BoxChars::ascii()
97    } else {
98        BoxChars::unicode()
99    };
100
101    let label_width = 14;
102    let data_width = 27;
103
104    let single_provider = result.providers.len() == 1;
105
106    if single_provider {
107        return render_single_provider(result, chars, label_width, data_width, use_colors);
108    }
109
110    render_multi_provider(result, chars, label_width, data_width, use_colors)
111}
112
113/// Human agreement label from the I²-derived band (METHODOLOGY.md §6).
114fn agreement_label(a: &super::AgreementInfo) -> String {
115    let band = match a.band {
116        super::statistics::AgreementBand::High => "High",
117        super::statistics::AgreementBand::Moderate => "Moderate",
118        super::statistics::AgreementBand::Low => "Low",
119        super::statistics::AgreementBand::VeryLow => "Very low",
120        super::statistics::AgreementBand::Insufficient => "Insufficient",
121    };
122    match a.i2 {
123        Some(i2) => format!("{} (I\u{b2} {:.0}%)", band, i2 * 100.0),
124        None => band.to_string(),
125    }
126}
127
128/// `±margin` string from a merged direction's CI (blank when absent/degenerate).
129fn ci_margin(ci: &Option<super::statistics::CiBounds>) -> String {
130    ci.as_ref()
131        .map(|c| (c.upper - c.lower) / 2.0)
132        .filter(|m| m.is_finite() && *m > 0.0)
133        .map(|m| format!(" \u{b1}{}", format_mbps(m)))
134        .unwrap_or_default()
135}
136
137fn render_multi_provider(
138    result: &SpeedTestResult,
139    chars: BoxChars,
140    label_width: usize,
141    data_width: usize,
142    use_colors: bool,
143) -> String {
144    let mut builder = ReportBuilder::new(label_width, data_width, chars);
145
146    // Top border + title
147    builder = builder.full_top_border().span_row(&format!(
148        "  {:^width$}",
149        "SPEEDQX RESULTS",
150        width = label_width + data_width + 3
151    ));
152
153    // ── L0: Headline (capacity ± CI, ping, PDV jitter) ──────────────
154    builder = builder.section_header("Capacity");
155
156    builder = builder.row(
157        "Download",
158        &format!(
159            "{}{}",
160            format_mbps(result.download_mbps),
161            ci_margin(&result.capacity.download_ci)
162        ),
163    );
164    builder = builder.row(
165        "Upload",
166        &format!(
167            "{}{}",
168            format_mbps(result.upload_mbps),
169            ci_margin(&result.capacity.upload_ci)
170        ),
171    );
172
173    if let Some(ping) = result.ping_ms {
174        builder = builder.row("Ping (min-RTT)", &format!("{:.1} ms", ping));
175    }
176    if let Some(jitter) = result.jitter_ms {
177        builder = builder.row("Jitter (PDV)", &format!("{:.1} ms", jitter));
178    }
179    if let Some(bb) = &result.bufferbloat {
180        builder = builder.row(
181            "Bufferbloat",
182            &format!("{} ({:.0} ms)", bb.grade.as_str(), bb.delta_ms),
183        );
184    }
185    if let Some(rpm) = result.rpm {
186        builder = builder.row("Responsiveness", &format!("{:.0} RPM", rpm));
187    }
188    if let Some(loss) = result.packet_loss_pct {
189        builder = builder.row("Packet Loss", &format!("{:.0}%", loss));
190    }
191
192    // ── L1: Quality (consensus, agreement, stability, exclusions) ───
193    builder = builder.section_header("Quality");
194
195    builder = builder.row(
196        "Consensus DL",
197        &format!(
198            "{}{}",
199            format_mbps(result.consensus.download),
200            ci_margin(&result.consensus.download_ci)
201        ),
202    );
203    builder = builder.row(
204        "Consensus UL",
205        &format!(
206            "{}{}",
207            format_mbps(result.consensus.upload),
208            ci_margin(&result.consensus.upload_ci)
209        ),
210    );
211    builder = builder.row("Agreement (DL)", &agreement_label(&result.agreement));
212    if let Some(ref ua) = result.upload_agreement {
213        builder = builder.row("Agreement (UL)", &agreement_label(ua));
214    }
215
216    if let Some(ref stability) = result.stability {
217        let dl_label = if stability.download_stable {
218            "Stable"
219        } else {
220            "Variable"
221        };
222        let ul_label = if stability.upload_stable {
223            "Stable"
224        } else {
225            "Variable"
226        };
227        builder = builder.row(
228            "Stability",
229            &format!(
230                "DL: {} (CV {:.0}%) / UL: {} (CV {:.0}%)",
231                dl_label,
232                stability.download_cv * 100.0,
233                ul_label,
234                stability.upload_cv * 100.0,
235            ),
236        );
237    }
238
239    // Providers excluded from the merge for insufficient samples.
240    if !result.merge_exclusions.is_empty() {
241        let list = result
242            .merge_exclusions
243            .iter()
244            .map(|e| {
245                format!(
246                    "{} {} ({} sample{})",
247                    e.provider,
248                    if e.direction == "download" {
249                        "DL"
250                    } else {
251                        "UL"
252                    },
253                    e.samples,
254                    if e.samples == 1 { "" } else { "s" },
255                )
256            })
257            .collect::<Vec<_>>()
258            .join(", ");
259        builder = builder.row("Excluded", &list);
260    }
261
262    builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
263
264    // ── L2: Per-provider breakdown (with availability) ──────────────
265    for provider in &result.providers {
266        builder = render_provider_section(builder, provider);
267    }
268
269    let mut output = builder.finish();
270
271    // Methodology footer.
272    let footer = format!("  SQX methodology {}", result.methodology_version);
273    if use_colors {
274        output.push_str(&format!("{}\n", owo_colors::OwoColorize::dimmed(&footer)));
275    } else {
276        output.push_str(&footer);
277        output.push('\n');
278    }
279    output.push('\n');
280    output
281}
282
283fn render_single_provider(
284    result: &SpeedTestResult,
285    chars: BoxChars,
286    label_width: usize,
287    data_width: usize,
288    _use_colors: bool,
289) -> String {
290    let mut builder = ReportBuilder::new(label_width, data_width, chars);
291
292    builder = builder.full_top_border().span_row(&format!(
293        "  {:^width$}",
294        "SPEEDQX RESULTS",
295        width = label_width + data_width + 3
296    ));
297
298    if let Some(provider) = result.providers.first() {
299        builder = builder.section_header(&provider.provider);
300
301        // Server
302        builder = builder.row("Server", &provider.server);
303
304        // Location
305        if let Some(ref loc) = provider.location {
306            builder = builder.row("Location", loc);
307        }
308
309        // Ping
310        if let Some(ping) = provider.ping_ms {
311            builder = builder.row("Ping", &format!("{:.1} ms", ping));
312        }
313
314        // Jitter
315        if let Some(jitter) = provider.jitter_ms {
316            builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
317        }
318
319        // Download / Upload
320        if let Some(dl) = provider.download_mbps {
321            builder = builder.row("Download", &format_mbps(dl));
322        }
323        if let Some(ul) = provider.upload_mbps {
324            builder = builder.row("Upload", &format_mbps(ul));
325        }
326
327        // Data transferred
328        builder = builder.row("DL Data", &format_bytes(provider.download_bytes));
329        builder = builder.row("UL Data", &format_bytes(provider.upload_bytes));
330
331        // Packet loss
332        if let Some(loss) = provider.packet_loss_pct {
333            builder = builder.row("Packet Loss", &format!("{}%", loss));
334        }
335
336        // Duration
337        builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
338    }
339
340    let mut output = builder.finish();
341    output.push('\n');
342    output
343}
344
345fn render_provider_section(
346    builder: ReportBuilder,
347    provider: &super::ProviderResult,
348) -> ReportBuilder {
349    let mut b = builder.section_header(&provider.provider);
350
351    // Platform-unavailable provider (schema parity; never on the CLI today).
352    if provider.availability == super::ProviderAvailability::UnavailablePlatform {
353        b = b.row("Status", "unavailable (platform)");
354        return b;
355    }
356
357    // Error case
358    if let Some(ref err) = provider.error {
359        b = b.row("Error", err);
360        return b;
361    }
362
363    // Server
364    b = b.row("Server", &provider.server);
365
366    // Location
367    if let Some(ref loc) = provider.location {
368        b = b.row("Location", loc);
369    }
370
371    // Ping
372    if let Some(ping) = provider.ping_ms {
373        b = b.row("Ping", &format!("{:.1} ms", ping));
374    }
375
376    // Jitter
377    if let Some(jitter) = provider.jitter_ms {
378        b = b.row("Jitter", &format!("{:.1} ms", jitter));
379    }
380
381    // Download / Upload
382    if let Some(dl) = provider.download_mbps {
383        b = b.row("Download", &format_mbps(dl));
384    }
385    if let Some(ul) = provider.upload_mbps {
386        b = b.row("Upload", &format_mbps(ul));
387    }
388
389    // Data transferred
390    b = b.row("DL Data", &format_bytes(provider.download_bytes));
391    b = b.row("UL Data", &format_bytes(provider.upload_bytes));
392
393    b
394}