Skip to main content

perfgate_app/
badge.rs

1//! SVG badge generation for perfgate performance status.
2//!
3//! Generates shields.io-compatible SVG badges showing performance status,
4//! individual metric values, or trend summaries from perfgate reports and
5//! comparisons.
6
7use perfgate_render::{format_metric_with_statistic, format_pct, format_value};
8use perfgate_types::{CompareReceipt, Metric, MetricStatus, PerfgateReport, VerdictStatus};
9use std::path::PathBuf;
10
11// ── Badge colours (shields.io palette) ───────────────────────────
12
13const COLOR_PASS: &str = "#4c1";
14const COLOR_WARN: &str = "#dfb317";
15const COLOR_FAIL: &str = "#e05d44";
16const COLOR_SKIP: &str = "#9f9f9f";
17
18// ── Public types ─────────────────────────────────────────────────
19
20/// Which kind of badge to generate.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BadgeType {
23    /// Overall verdict badge, e.g. "performance | passing".
24    Status,
25    /// Single-metric badge, e.g. "wall_ms | 142 ms (+3.20%)".
26    Metric,
27    /// Trend badge, e.g. "perf trend | stable".
28    Trend,
29}
30
31/// Visual style of the badge.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum BadgeStyle {
34    /// Rounded ends (default shields.io style).
35    #[default]
36    Flat,
37    /// Square ends.
38    FlatSquare,
39}
40
41/// Everything needed to produce a badge.
42#[derive(Debug, Clone)]
43pub struct BadgeRequest {
44    /// Path to a compare receipt **or** report receipt.
45    pub input_path: PathBuf,
46    /// What kind of badge to produce.
47    pub badge_type: BadgeType,
48    /// Visual style.
49    pub style: BadgeStyle,
50    /// When `badge_type == Metric`, which metric to render.
51    pub metric: Option<String>,
52    /// Where to write the SVG. `None` means stdout.
53    pub output_path: Option<PathBuf>,
54}
55
56/// Result of badge generation.
57#[derive(Debug, Clone)]
58pub struct BadgeOutcome {
59    /// The generated SVG string.
60    pub svg: String,
61}
62
63/// Intermediate representation of a badge, before SVG rendering.
64#[derive(Debug, Clone, PartialEq)]
65pub struct Badge {
66    pub label: String,
67    pub message: String,
68    pub color: String,
69    pub style: BadgeStyle,
70}
71
72// ── Use case ─────────────────────────────────────────────────────
73
74pub struct BadgeUseCase;
75
76/// Input that has been parsed from a file — either a compare receipt or a
77/// report.
78pub enum BadgeInput {
79    Compare(CompareReceipt),
80    Report(PerfgateReport),
81}
82
83impl BadgeUseCase {
84    /// Build a badge from an already-parsed input.
85    pub fn execute(
86        &self,
87        input: &BadgeInput,
88        badge_type: BadgeType,
89        style: BadgeStyle,
90        metric_name: Option<&str>,
91    ) -> anyhow::Result<BadgeOutcome> {
92        let badge = match badge_type {
93            BadgeType::Status => status_badge(input, style),
94            BadgeType::Metric => {
95                let name = metric_name.ok_or_else(|| {
96                    anyhow::anyhow!("--metric is required when --type metric is used")
97                })?;
98                metric_badge(input, style, name)?
99            }
100            BadgeType::Trend => trend_badge(input, style),
101        };
102        Ok(BadgeOutcome {
103            svg: render_svg(&badge),
104        })
105    }
106}
107
108// ── Badge builders ───────────────────────────────────────────────
109
110fn status_badge(input: &BadgeInput, style: BadgeStyle) -> Badge {
111    let (status_label, color) = match input {
112        BadgeInput::Compare(c) => verdict_status_label_color(c.verdict.status),
113        BadgeInput::Report(r) => verdict_status_label_color(r.verdict.status),
114    };
115    Badge {
116        label: "performance".to_string(),
117        message: status_label.to_string(),
118        color: color.to_string(),
119        style,
120    }
121}
122
123fn metric_badge(input: &BadgeInput, style: BadgeStyle, metric_name: &str) -> anyhow::Result<Badge> {
124    let compare = match input {
125        BadgeInput::Compare(c) => c,
126        BadgeInput::Report(r) => r
127            .compare
128            .as_ref()
129            .ok_or_else(|| anyhow::anyhow!("report has no compare receipt (no baseline?)"))?,
130    };
131
132    let metric = Metric::parse_key(metric_name)
133        .ok_or_else(|| anyhow::anyhow!("unknown metric: {metric_name}"))?;
134
135    let delta = compare
136        .deltas
137        .get(&metric)
138        .ok_or_else(|| anyhow::anyhow!("metric {metric_name} not found in deltas"))?;
139
140    let label = format_metric_with_statistic(metric, delta.statistic);
141    let value_str = format_value(metric, delta.current);
142    let unit = metric.display_unit();
143    let pct = format_pct(delta.pct);
144    let message = format!("{value_str} {unit} ({pct})");
145    let color = metric_status_color(delta.status).to_string();
146
147    Ok(Badge {
148        label,
149        message,
150        color,
151        style,
152    })
153}
154
155fn trend_badge(input: &BadgeInput, style: BadgeStyle) -> Badge {
156    let compare = match input {
157        BadgeInput::Compare(c) => Some(c),
158        BadgeInput::Report(r) => r.compare.as_ref(),
159    };
160
161    let (trend_label, color) = match compare {
162        Some(c) => {
163            let worst = worst_metric_status(c);
164            match worst {
165                MetricStatus::Pass => ("stable", COLOR_PASS),
166                MetricStatus::Warn => ("degraded", COLOR_WARN),
167                MetricStatus::Fail => ("regressed", COLOR_FAIL),
168                MetricStatus::Skip => ("unknown", COLOR_SKIP),
169            }
170        }
171        None => ("unknown", COLOR_SKIP),
172    };
173
174    Badge {
175        label: "perf trend".to_string(),
176        message: trend_label.to_string(),
177        color: color.to_string(),
178        style,
179    }
180}
181
182// ── Helpers ──────────────────────────────────────────────────────
183
184fn verdict_status_label_color(status: VerdictStatus) -> (&'static str, &'static str) {
185    match status {
186        VerdictStatus::Pass => ("passing", COLOR_PASS),
187        VerdictStatus::Warn => ("warning", COLOR_WARN),
188        VerdictStatus::Fail => ("failing", COLOR_FAIL),
189        VerdictStatus::Skip => ("skipped", COLOR_SKIP),
190    }
191}
192
193fn metric_status_color(status: MetricStatus) -> &'static str {
194    match status {
195        MetricStatus::Pass => COLOR_PASS,
196        MetricStatus::Warn => COLOR_WARN,
197        MetricStatus::Fail => COLOR_FAIL,
198        MetricStatus::Skip => COLOR_SKIP,
199    }
200}
201
202fn worst_metric_status(c: &CompareReceipt) -> MetricStatus {
203    let mut worst = MetricStatus::Pass;
204    for delta in c.deltas.values() {
205        worst = match (worst, delta.status) {
206            (MetricStatus::Fail, _) | (_, MetricStatus::Fail) => MetricStatus::Fail,
207            (MetricStatus::Warn, _) | (_, MetricStatus::Warn) => MetricStatus::Warn,
208            (MetricStatus::Skip, _) | (_, MetricStatus::Skip) => MetricStatus::Skip,
209            _ => MetricStatus::Pass,
210        };
211    }
212    if c.deltas.is_empty() {
213        return MetricStatus::Skip;
214    }
215    worst
216}
217
218// ── Text width estimation ────────────────────────────────────────
219
220/// Approximate the rendered pixel width of `text` at 11px Verdana (the
221/// shields.io default). Uses per-character width buckets derived from the
222/// shields.io source.
223pub fn text_width(text: &str) -> f64 {
224    let mut w: f64 = 0.0;
225    for ch in text.chars() {
226        w += char_width(ch);
227    }
228    w
229}
230
231/// Per-character width at 11px Verdana, matching the shields.io badge
232/// generator. The widths are averages for the character classes.
233fn char_width(ch: char) -> f64 {
234    match ch {
235        // Narrow characters
236        'i' | 'l' | '!' | '|' | ',' | '.' | ':' | ';' | '\'' => 3.7,
237        'I' | 'j' | 'f' | 'r' | 't' | '(' | ')' | '[' | ']' | '{' | '}' => 4.5,
238        // Slightly narrow
239        '1' => 5.0,
240        // Medium-narrow
241        ' ' | '-' | '_' => 5.0,
242        // Wide uppercase
243        'M' | 'W' => 9.5,
244        'm' | 'w' => 8.5,
245        // Standard uppercase
246        'A'..='Z' => 7.5,
247        // Standard lowercase / digits
248        'a'..='z' | '0'..='9' => 6.5,
249        // Other characters (symbols, unicode)
250        '+' | '=' | '<' | '>' | '~' | '^' | '%' | '#' | '@' | '&' | '*' | '/' | '\\' | '?'
251        | '$' => 6.5,
252        _ => 6.5,
253    }
254}
255
256// ── SVG rendering ────────────────────────────────────────────────
257
258/// Render a `Badge` to an SVG string, compatible with shields.io.
259pub fn render_svg(badge: &Badge) -> String {
260    let label_width = text_width(&badge.label) + 10.0; // 5px padding each side
261    let msg_width = text_width(&badge.message) + 10.0;
262    let total_width = label_width + msg_width;
263
264    let label_x = label_width / 2.0;
265    let msg_x = label_width + msg_width / 2.0;
266
267    let radius = match badge.style {
268        BadgeStyle::Flat => 3,
269        BadgeStyle::FlatSquare => 0,
270    };
271
272    let gradient = match badge.style {
273        BadgeStyle::Flat => {
274            r##"<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>"##
275        }
276        BadgeStyle::FlatSquare => "",
277    };
278
279    let gradient_fill = match badge.style {
280        BadgeStyle::Flat => r##"<rect rx="3" width="{tw}" height="20" fill="url(#s)"/>"##,
281        BadgeStyle::FlatSquare => "",
282    };
283
284    // Build the gradient overlay with the actual width
285    let gradient_overlay = gradient_fill.replace("{tw}", &format!("{total_width:.0}"));
286
287    format!(
288        r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{tw:.0}" height="20" role="img" aria-label="{label}: {msg}"><title>{label}: {msg}</title>{gradient}<clipPath id="r"><rect width="{tw:.0}" height="20" rx="{radius}" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="{lw:.0}" height="20" fill="#555"/><rect x="{lw:.0}" width="{mw:.0}" height="20" fill="{color}"/>{gradient_overlay}</g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="{lx:.0}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{ltl:.0}">{label}</text><text x="{lx:.0}" y="140" transform="scale(.1)" fill="#fff" textLength="{ltl:.0}">{label}</text><text aria-hidden="true" x="{mx:.0}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{mtl:.0}">{msg}</text><text x="{mx:.0}" y="140" transform="scale(.1)" fill="#fff" textLength="{mtl:.0}">{msg}</text></g></svg>"##,
289        tw = total_width,
290        lw = label_width,
291        mw = msg_width,
292        color = badge.color,
293        gradient = gradient,
294        gradient_overlay = gradient_overlay,
295        radius = radius,
296        lx = label_x * 10.0,
297        mx = msg_x * 10.0,
298        ltl = (label_width - 10.0) * 10.0,
299        mtl = (msg_width - 10.0) * 10.0,
300        label = xml_escape(&badge.label),
301        msg = xml_escape(&badge.message),
302    )
303}
304
305fn xml_escape(s: &str) -> String {
306    s.replace('&', "&amp;")
307        .replace('<', "&lt;")
308        .replace('>', "&gt;")
309        .replace('"', "&quot;")
310        .replace('\'', "&#39;")
311}
312
313// ── Tests ────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use perfgate_types::{
319        BenchMeta, Budget, CompareRef, Delta, Direction, MetricStatistic, ToolInfo, Verdict,
320        VerdictCounts,
321    };
322    use std::collections::BTreeMap;
323
324    fn make_compare(verdict_status: VerdictStatus, metric_status: MetricStatus) -> CompareReceipt {
325        let mut budgets = BTreeMap::new();
326        budgets.insert(Metric::WallMs, Budget::new(0.2, 0.1, Direction::Lower));
327
328        let mut deltas = BTreeMap::new();
329        deltas.insert(
330            Metric::WallMs,
331            Delta {
332                baseline: 100.0,
333                current: 115.0,
334                ratio: 1.15,
335                pct: 0.15,
336                regression: 0.15,
337                statistic: MetricStatistic::Median,
338                significance: None,
339                cv: None,
340                noise_threshold: None,
341                status: metric_status,
342            },
343        );
344
345        CompareReceipt {
346            schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
347            tool: ToolInfo {
348                name: "perfgate".into(),
349                version: "0.1.0".into(),
350            },
351            bench: BenchMeta {
352                name: "bench".into(),
353                cwd: None,
354                command: vec!["true".into()],
355                repeat: 1,
356                warmup: 0,
357                work_units: None,
358                timeout_ms: None,
359            },
360            baseline_ref: CompareRef {
361                path: None,
362                run_id: None,
363            },
364            current_ref: CompareRef {
365                path: None,
366                run_id: None,
367            },
368            budgets,
369            deltas,
370            verdict: Verdict {
371                status: verdict_status,
372                counts: VerdictCounts {
373                    pass: if verdict_status == VerdictStatus::Pass {
374                        1
375                    } else {
376                        0
377                    },
378                    warn: if verdict_status == VerdictStatus::Warn {
379                        1
380                    } else {
381                        0
382                    },
383                    fail: if verdict_status == VerdictStatus::Fail {
384                        1
385                    } else {
386                        0
387                    },
388                    skip: 0,
389                },
390                reasons: vec![],
391            },
392        }
393    }
394
395    fn make_report(verdict_status: VerdictStatus) -> PerfgateReport {
396        let compare = make_compare(verdict_status, MetricStatus::Pass);
397        PerfgateReport {
398            report_type: perfgate_types::REPORT_SCHEMA_V1.to_string(),
399            verdict: compare.verdict.clone(),
400            compare: Some(compare),
401            findings: vec![],
402            summary: perfgate_types::ReportSummary {
403                pass_count: 1,
404                warn_count: 0,
405                fail_count: 0,
406                skip_count: 0,
407                total_count: 1,
408            },
409            profile_path: None,
410        }
411    }
412
413    // ── Color mapping ────────────────────────────────────────────
414
415    #[test]
416    fn verdict_pass_is_green() {
417        let (label, color) = verdict_status_label_color(VerdictStatus::Pass);
418        assert_eq!(label, "passing");
419        assert_eq!(color, COLOR_PASS);
420    }
421
422    #[test]
423    fn verdict_warn_is_yellow() {
424        let (label, color) = verdict_status_label_color(VerdictStatus::Warn);
425        assert_eq!(label, "warning");
426        assert_eq!(color, COLOR_WARN);
427    }
428
429    #[test]
430    fn verdict_fail_is_red() {
431        let (label, color) = verdict_status_label_color(VerdictStatus::Fail);
432        assert_eq!(label, "failing");
433        assert_eq!(color, COLOR_FAIL);
434    }
435
436    #[test]
437    fn verdict_skip_is_grey() {
438        let (label, color) = verdict_status_label_color(VerdictStatus::Skip);
439        assert_eq!(label, "skipped");
440        assert_eq!(color, COLOR_SKIP);
441    }
442
443    #[test]
444    fn metric_status_colors_match() {
445        assert_eq!(metric_status_color(MetricStatus::Pass), COLOR_PASS);
446        assert_eq!(metric_status_color(MetricStatus::Warn), COLOR_WARN);
447        assert_eq!(metric_status_color(MetricStatus::Fail), COLOR_FAIL);
448        assert_eq!(metric_status_color(MetricStatus::Skip), COLOR_SKIP);
449    }
450
451    // ── Text width ───────────────────────────────────────────────
452
453    #[test]
454    fn text_width_empty_is_zero() {
455        assert!((text_width("") - 0.0).abs() < f64::EPSILON);
456    }
457
458    #[test]
459    fn text_width_increases_with_length() {
460        let short = text_width("hi");
461        let long = text_width("performance");
462        assert!(long > short, "long={long}, short={short}");
463    }
464
465    #[test]
466    fn narrow_chars_are_narrower_than_wide() {
467        let narrow = text_width("iii");
468        let wide = text_width("MMM");
469        assert!(wide > narrow, "wide={wide}, narrow={narrow}");
470    }
471
472    // ── SVG rendering ────────────────────────────────────────────
473
474    #[test]
475    fn svg_contains_label_and_message() {
476        let badge = Badge {
477            label: "performance".into(),
478            message: "passing".into(),
479            color: COLOR_PASS.into(),
480            style: BadgeStyle::Flat,
481        };
482        let svg = render_svg(&badge);
483        assert!(svg.contains("performance"), "missing label");
484        assert!(svg.contains("passing"), "missing message");
485        assert!(svg.contains(COLOR_PASS), "missing color");
486        assert!(svg.starts_with("<svg"), "not an SVG");
487    }
488
489    #[test]
490    fn flat_square_has_zero_radius() {
491        let badge = Badge {
492            label: "test".into(),
493            message: "ok".into(),
494            color: COLOR_PASS.into(),
495            style: BadgeStyle::FlatSquare,
496        };
497        let svg = render_svg(&badge);
498        assert!(svg.contains(r#"rx="0""#), "expected rx=0 for flat-square");
499    }
500
501    #[test]
502    fn flat_has_rounded_radius() {
503        let badge = Badge {
504            label: "test".into(),
505            message: "ok".into(),
506            color: COLOR_PASS.into(),
507            style: BadgeStyle::Flat,
508        };
509        let svg = render_svg(&badge);
510        assert!(svg.contains(r#"rx="3""#), "expected rx=3 for flat");
511    }
512
513    #[test]
514    fn svg_escapes_special_characters() {
515        let badge = Badge {
516            label: "a<b".into(),
517            message: "c&d".into(),
518            color: COLOR_PASS.into(),
519            style: BadgeStyle::Flat,
520        };
521        let svg = render_svg(&badge);
522        assert!(svg.contains("a&lt;b"), "< not escaped");
523        assert!(svg.contains("c&amp;d"), "& not escaped");
524    }
525
526    // ── Status badge ─────────────────────────────────────────────
527
528    #[test]
529    fn status_badge_from_compare_pass() {
530        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
531        let badge = status_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
532        assert_eq!(badge.label, "performance");
533        assert_eq!(badge.message, "passing");
534        assert_eq!(badge.color, COLOR_PASS);
535    }
536
537    #[test]
538    fn status_badge_from_compare_fail() {
539        let compare = make_compare(VerdictStatus::Fail, MetricStatus::Fail);
540        let badge = status_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
541        assert_eq!(badge.message, "failing");
542        assert_eq!(badge.color, COLOR_FAIL);
543    }
544
545    #[test]
546    fn status_badge_from_report() {
547        let report = make_report(VerdictStatus::Warn);
548        let badge = status_badge(&BadgeInput::Report(report), BadgeStyle::FlatSquare);
549        assert_eq!(badge.message, "warning");
550        assert_eq!(badge.color, COLOR_WARN);
551        assert_eq!(badge.style, BadgeStyle::FlatSquare);
552    }
553
554    // ── Metric badge ─────────────────────────────────────────────
555
556    #[test]
557    fn metric_badge_from_compare() {
558        let compare = make_compare(VerdictStatus::Warn, MetricStatus::Warn);
559        let badge =
560            metric_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat, "wall_ms").unwrap();
561        assert_eq!(badge.label, "wall_ms");
562        assert!(badge.message.contains("115"), "missing current value");
563        assert!(badge.message.contains("ms"), "missing unit");
564        assert!(
565            badge.message.contains("+15.00%"),
566            "missing pct: {}",
567            badge.message
568        );
569        assert_eq!(badge.color, COLOR_WARN);
570    }
571
572    #[test]
573    fn metric_badge_unknown_metric_errors() {
574        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
575        let result = metric_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat, "no_such");
576        assert!(result.is_err());
577    }
578
579    #[test]
580    fn metric_badge_missing_delta_errors() {
581        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
582        let result = metric_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat, "cpu_ms");
583        assert!(result.is_err());
584    }
585
586    #[test]
587    fn metric_badge_from_report_without_compare_errors() {
588        let report = PerfgateReport {
589            report_type: perfgate_types::REPORT_SCHEMA_V1.to_string(),
590            verdict: Verdict {
591                status: VerdictStatus::Pass,
592                counts: VerdictCounts {
593                    pass: 0,
594                    warn: 0,
595                    fail: 0,
596                    skip: 0,
597                },
598                reasons: vec![],
599            },
600            compare: None,
601            findings: vec![],
602            summary: perfgate_types::ReportSummary {
603                pass_count: 0,
604                warn_count: 0,
605                fail_count: 0,
606                skip_count: 0,
607                total_count: 0,
608            },
609            profile_path: None,
610        };
611        let result = metric_badge(&BadgeInput::Report(report), BadgeStyle::Flat, "wall_ms");
612        assert!(result.is_err());
613    }
614
615    // ── Trend badge ──────────────────────────────────────────────
616
617    #[test]
618    fn trend_badge_stable_when_all_pass() {
619        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
620        let badge = trend_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
621        assert_eq!(badge.label, "perf trend");
622        assert_eq!(badge.message, "stable");
623        assert_eq!(badge.color, COLOR_PASS);
624    }
625
626    #[test]
627    fn trend_badge_degraded_when_warn() {
628        let compare = make_compare(VerdictStatus::Warn, MetricStatus::Warn);
629        let badge = trend_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
630        assert_eq!(badge.message, "degraded");
631        assert_eq!(badge.color, COLOR_WARN);
632    }
633
634    #[test]
635    fn trend_badge_regressed_when_fail() {
636        let compare = make_compare(VerdictStatus::Fail, MetricStatus::Fail);
637        let badge = trend_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
638        assert_eq!(badge.message, "regressed");
639        assert_eq!(badge.color, COLOR_FAIL);
640    }
641
642    #[test]
643    fn trend_badge_unknown_when_no_compare() {
644        let report = PerfgateReport {
645            report_type: perfgate_types::REPORT_SCHEMA_V1.to_string(),
646            verdict: Verdict {
647                status: VerdictStatus::Skip,
648                counts: VerdictCounts {
649                    pass: 0,
650                    warn: 0,
651                    fail: 0,
652                    skip: 0,
653                },
654                reasons: vec![],
655            },
656            compare: None,
657            findings: vec![],
658            summary: perfgate_types::ReportSummary {
659                pass_count: 0,
660                warn_count: 0,
661                fail_count: 0,
662                skip_count: 0,
663                total_count: 0,
664            },
665            profile_path: None,
666        };
667        let badge = trend_badge(&BadgeInput::Report(report), BadgeStyle::Flat);
668        assert_eq!(badge.message, "unknown");
669        assert_eq!(badge.color, COLOR_SKIP);
670    }
671
672    #[test]
673    fn trend_badge_empty_deltas_is_unknown() {
674        let mut compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
675        compare.deltas.clear();
676        let badge = trend_badge(&BadgeInput::Compare(compare), BadgeStyle::Flat);
677        assert_eq!(badge.message, "unknown");
678    }
679
680    // ── Use case end-to-end ──────────────────────────────────────
681
682    #[test]
683    fn usecase_status_from_compare() {
684        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
685        let uc = BadgeUseCase;
686        let outcome = uc
687            .execute(
688                &BadgeInput::Compare(compare),
689                BadgeType::Status,
690                BadgeStyle::Flat,
691                None,
692            )
693            .unwrap();
694        assert!(outcome.svg.starts_with("<svg"));
695        assert!(outcome.svg.contains("passing"));
696    }
697
698    #[test]
699    fn usecase_metric_requires_metric_name() {
700        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
701        let uc = BadgeUseCase;
702        let result = uc.execute(
703            &BadgeInput::Compare(compare),
704            BadgeType::Metric,
705            BadgeStyle::Flat,
706            None,
707        );
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn usecase_metric_with_name() {
713        let compare = make_compare(VerdictStatus::Pass, MetricStatus::Pass);
714        let uc = BadgeUseCase;
715        let outcome = uc
716            .execute(
717                &BadgeInput::Compare(compare),
718                BadgeType::Metric,
719                BadgeStyle::Flat,
720                Some("wall_ms"),
721            )
722            .unwrap();
723        assert!(outcome.svg.contains("wall_ms"));
724    }
725
726    #[test]
727    fn usecase_trend() {
728        let compare = make_compare(VerdictStatus::Fail, MetricStatus::Fail);
729        let uc = BadgeUseCase;
730        let outcome = uc
731            .execute(
732                &BadgeInput::Compare(compare),
733                BadgeType::Trend,
734                BadgeStyle::FlatSquare,
735                None,
736            )
737            .unwrap();
738        assert!(outcome.svg.contains("regressed"));
739    }
740
741    // ── xml_escape ───────────────────────────────────────────────
742
743    #[test]
744    fn xml_escape_covers_all_entities() {
745        let raw = r#"<>&"'"#;
746        let escaped = xml_escape(raw);
747        assert_eq!(escaped, "&lt;&gt;&amp;&quot;&#39;");
748    }
749
750    // ── worst_metric_status ──────────────────────────────────────
751
752    #[test]
753    fn worst_metric_status_picks_fail_over_warn() {
754        let mut compare = make_compare(VerdictStatus::Fail, MetricStatus::Warn);
755        compare.deltas.insert(
756            Metric::MaxRssKb,
757            Delta {
758                baseline: 100.0,
759                current: 200.0,
760                ratio: 2.0,
761                pct: 1.0,
762                regression: 1.0,
763                statistic: MetricStatistic::Median,
764                significance: None,
765                cv: None,
766                noise_threshold: None,
767                status: MetricStatus::Fail,
768            },
769        );
770        assert_eq!(worst_metric_status(&compare), MetricStatus::Fail);
771    }
772}