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        // 'Insufficient' means the merge declined to grade agreement (< 3
124        // qualifying sources) — pairing the label with a concrete I² number
125        // would contradict that call, so the percentage is withheld too.
126        Some(i2) if !matches!(a.band, super::statistics::AgreementBand::Insufficient) => {
127            format!("{} (I\u{b2} {:.0}%)", band, i2 * 100.0)
128        }
129        _ => band.to_string(),
130    }
131}
132
133/// `±margin` string from a merged direction's CI (blank when absent/degenerate).
134fn ci_margin(ci: &Option<super::statistics::CiBounds>) -> String {
135    ci.as_ref()
136        .map(|c| (c.upper - c.lower) / 2.0)
137        .filter(|m| m.is_finite() && *m > 0.0)
138        .map(|m| format!(" \u{b1}{}", format_mbps(m)))
139        .unwrap_or_default()
140}
141
142fn render_multi_provider(
143    result: &SpeedTestResult,
144    chars: BoxChars,
145    label_width: usize,
146    data_width: usize,
147    use_colors: bool,
148) -> String {
149    let mut builder = ReportBuilder::new(label_width, data_width, chars);
150
151    // Top border + title
152    builder = builder.full_top_border().span_row(&format!(
153        "  {:^width$}",
154        "SPEEDQX RESULTS",
155        width = label_width + data_width + 3
156    ));
157
158    // ── L0: Headline (capacity ± CI, ping, PDV jitter) ──────────────
159    builder = builder.section_header("Capacity");
160
161    builder = builder.row(
162        "Download",
163        &format!(
164            "{}{}",
165            format_mbps(result.download_mbps),
166            ci_margin(&result.capacity.download_ci)
167        ),
168    );
169    builder = builder.row(
170        "Upload",
171        &format!(
172            "{}{}",
173            format_mbps(result.upload_mbps),
174            ci_margin(&result.capacity.upload_ci)
175        ),
176    );
177
178    if let Some(ping) = result.ping_ms {
179        builder = builder.row("Ping (min-RTT)", &format!("{:.1} ms", ping));
180    }
181    if let Some(jitter) = result.jitter_ms {
182        builder = builder.row("Jitter (PDV)", &format!("{:.1} ms", jitter));
183    }
184    if let Some(bb) = &result.bufferbloat {
185        builder = builder.row(
186            "Bufferbloat",
187            &format!("{} ({:.0} ms)", bb.grade.as_str(), bb.delta_ms),
188        );
189    }
190    if let Some(rpm) = result.rpm {
191        builder = builder.row("Responsiveness", &format!("{:.0} RPM", rpm));
192    }
193    if let Some(loss) = result.packet_loss_pct {
194        builder = builder.row("Packet Loss", &format!("{:.0}%", loss));
195    }
196
197    // ── L1: Quality (consensus, agreement, stability, exclusions) ───
198    builder = builder.section_header("Quality");
199
200    builder = builder.row(
201        "Consensus DL",
202        &format!(
203            "{}{}",
204            format_mbps(result.consensus.download),
205            ci_margin(&result.consensus.download_ci)
206        ),
207    );
208    builder = builder.row(
209        "Consensus UL",
210        &format!(
211            "{}{}",
212            format_mbps(result.consensus.upload),
213            ci_margin(&result.consensus.upload_ci)
214        ),
215    );
216    builder = builder.row("Agreement (DL)", &agreement_label(&result.agreement));
217    if let Some(ref ua) = result.upload_agreement {
218        builder = builder.row("Agreement (UL)", &agreement_label(ua));
219    }
220
221    if let Some(ref stability) = result.stability {
222        let dl_label = if stability.download_stable {
223            "Stable"
224        } else {
225            "Variable"
226        };
227        let ul_label = if stability.upload_stable {
228            "Stable"
229        } else {
230            "Variable"
231        };
232        builder = builder.row(
233            "Stability",
234            &format!(
235                "DL: {} (CV {:.0}%) / UL: {} (CV {:.0}%)",
236                dl_label,
237                stability.download_cv * 100.0,
238                ul_label,
239                stability.upload_cv * 100.0,
240            ),
241        );
242    }
243
244    // Providers excluded from the merge for insufficient samples.
245    if !result.merge_exclusions.is_empty() {
246        let list = result
247            .merge_exclusions
248            .iter()
249            .map(|e| {
250                format!(
251                    "{} {} ({} sample{})",
252                    e.provider,
253                    if e.direction == "download" {
254                        "DL"
255                    } else {
256                        "UL"
257                    },
258                    e.samples,
259                    if e.samples == 1 { "" } else { "s" },
260                )
261            })
262            .collect::<Vec<_>>()
263            .join(", ");
264        builder = builder.row("Excluded", &list);
265    }
266
267    builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
268
269    // ── L2: Per-provider breakdown (with availability) ──────────────
270    for provider in &result.providers {
271        builder = render_provider_section(builder, provider);
272    }
273
274    let mut output = builder.finish();
275
276    // Methodology footer.
277    let footer = format!("  SQX methodology {}", result.methodology_version);
278    if use_colors {
279        output.push_str(&format!("{}\n", owo_colors::OwoColorize::dimmed(&footer)));
280    } else {
281        output.push_str(&footer);
282        output.push('\n');
283    }
284    output.push('\n');
285    output
286}
287
288fn render_single_provider(
289    result: &SpeedTestResult,
290    chars: BoxChars,
291    label_width: usize,
292    data_width: usize,
293    _use_colors: bool,
294) -> String {
295    let mut builder = ReportBuilder::new(label_width, data_width, chars);
296
297    builder = builder.full_top_border().span_row(&format!(
298        "  {:^width$}",
299        "SPEEDQX RESULTS",
300        width = label_width + data_width + 3
301    ));
302
303    if let Some(provider) = result.providers.first() {
304        builder = builder.section_header(&provider.provider);
305
306        // Server
307        builder = builder.row("Server", &provider.server);
308
309        // Location
310        if let Some(ref loc) = provider.location {
311            builder = builder.row("Location", loc);
312        }
313
314        // Ping
315        if let Some(ping) = provider.ping_ms {
316            builder = builder.row("Ping", &format!("{:.1} ms", ping));
317        }
318
319        // Jitter
320        if let Some(jitter) = provider.jitter_ms {
321            builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
322        }
323
324        // Download / Upload
325        if let Some(dl) = provider.download_mbps {
326            builder = builder.row("Download", &format_mbps(dl));
327        }
328        if let Some(ul) = provider.upload_mbps {
329            builder = builder.row("Upload", &format_mbps(ul));
330        }
331
332        // Data transferred
333        builder = builder.row("DL Data", &format_bytes(provider.download_bytes));
334        builder = builder.row("UL Data", &format_bytes(provider.upload_bytes));
335
336        // Packet loss
337        if let Some(loss) = provider.packet_loss_pct {
338            builder = builder.row("Packet Loss", &format!("{}%", loss));
339        }
340
341        // Duration
342        builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
343    }
344
345    let mut output = builder.finish();
346    output.push('\n');
347    output
348}
349
350fn render_provider_section(
351    builder: ReportBuilder,
352    provider: &super::ProviderResult,
353) -> ReportBuilder {
354    let mut b = builder.section_header(&provider.provider);
355
356    // Platform-unavailable provider (schema parity; never on the CLI today).
357    if provider.availability == super::ProviderAvailability::UnavailablePlatform {
358        b = b.row("Status", "unavailable (platform)");
359        return b;
360    }
361
362    // Error case
363    if let Some(ref err) = provider.error {
364        b = b.row("Error", err);
365        return b;
366    }
367
368    // Server
369    b = b.row("Server", &provider.server);
370
371    // Location
372    if let Some(ref loc) = provider.location {
373        b = b.row("Location", loc);
374    }
375
376    // Ping
377    if let Some(ping) = provider.ping_ms {
378        b = b.row("Ping", &format!("{:.1} ms", ping));
379    }
380
381    // Jitter
382    if let Some(jitter) = provider.jitter_ms {
383        b = b.row("Jitter", &format!("{:.1} ms", jitter));
384    }
385
386    // Download / Upload
387    if let Some(dl) = provider.download_mbps {
388        b = b.row("Download", &format_mbps(dl));
389    }
390    if let Some(ul) = provider.upload_mbps {
391        b = b.row("Upload", &format_mbps(ul));
392    }
393
394    // Data transferred
395    b = b.row("DL Data", &format_bytes(provider.download_bytes));
396    b = b.row("UL Data", &format_bytes(provider.upload_bytes));
397
398    b
399}