Skip to main content

dev_tools/
html.rs

1//! HTML meta-report rendering.
2//!
3//! [`multi_report_to_html`] takes a [`MultiReport`] and produces a
4//! single self-contained HTML document: inline CSS, inline SVG charts,
5//! no external assets, no JavaScript dependencies. The page is fully
6//! readable with JavaScript disabled; collapse/expand uses native
7//! HTML5 `<details>` elements.
8//!
9//! Colors are CSS custom properties at the top of the document, sourced
10//! from [`crate::brand`]. The brand kit lands later; replacing those
11//! constants automatically re-themes every report.
12//!
13//! Output is deterministic for a given `MultiReport`: no clock reads,
14//! no random IDs, iteration order matches the input.
15//!
16//! Use via the [`MultiReportHtmlExt`](crate::MultiReportHtmlExt) trait
17//! to call `multi.to_html()` directly, or call this function on a
18//! borrowed `MultiReport`.
19
20// HTML emission deliberately uses `write!(..., "...\n", ...)` so the
21// runs of literal HTML/CSS read top-to-bottom without mixing `writeln!`
22// and `push_str` styles.
23#![allow(clippy::write_with_newline)]
24
25use std::fmt::Write;
26
27use dev_report::{CheckResult, MultiReport, Report, Severity, Verdict};
28
29use crate::brand;
30
31/// Render `multi` as a self-contained HTML document.
32///
33/// See [`crate::html`] for output guarantees.
34///
35/// # Example
36///
37/// ```
38/// use dev_report::{CheckResult, MultiReport, Report};
39///
40/// let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
41/// bench.push(CheckResult::pass("hot"));
42/// let mut multi = MultiReport::new("crate", "0.1.0");
43/// multi.push(bench);
44///
45/// let html = dev_tools::html::multi_report_to_html(&multi);
46/// assert!(html.starts_with("<!DOCTYPE html>"));
47/// assert!(html.contains("</html>"));
48/// ```
49pub fn multi_report_to_html(multi: &MultiReport) -> String {
50    let mut out = String::with_capacity(16 * 1024);
51    write_doc(&mut out, multi);
52    out
53}
54
55fn write_doc(out: &mut String, multi: &MultiReport) {
56    let overall = multi.overall_verdict();
57    let (pass, fail, warn, skip) = multi.verdict_counts();
58    let total = pass + fail + warn + skip;
59
60    out.push_str("<!DOCTYPE html>\n");
61    out.push_str("<html lang=\"en\">\n<head>\n");
62    out.push_str("<meta charset=\"UTF-8\">\n");
63    out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
64    out.push_str("<title>");
65    write_html_text(out, &multi.subject);
66    out.push_str(" v");
67    write_html_text(out, &multi.subject_version);
68    out.push_str(" — dev-tools meta-report</title>\n");
69    write_inline_css(out);
70    out.push_str("</head>\n<body>\n");
71
72    write_header(out, multi, overall, total);
73    write_summary_section(out, pass, fail, warn, skip);
74    write_duration_section(out, multi);
75    write_producers_section(out, multi);
76    write_footer(out);
77
78    out.push_str("</body>\n</html>\n");
79}
80
81fn write_inline_css(out: &mut String) {
82    out.push_str("<style>\n:root {\n");
83    write!(out, "    --color-accent: {};\n", brand::COLOR_ACCENT).unwrap();
84    write!(out, "    --color-pass: {};\n", brand::COLOR_PASS).unwrap();
85    write!(out, "    --color-fail: {};\n", brand::COLOR_FAIL).unwrap();
86    write!(out, "    --color-warn: {};\n", brand::COLOR_WARN).unwrap();
87    write!(out, "    --color-lint: {};\n", brand::COLOR_LINT).unwrap();
88    write!(out, "    --color-bg: {};\n", brand::COLOR_BG).unwrap();
89    write!(out, "    --color-fg: {};\n", brand::COLOR_FG).unwrap();
90    out.push_str("    --color-muted: #888;\n");
91    out.push_str("    --color-surface: #1a1f26;\n");
92    out.push_str("    --color-border: #2a3038;\n");
93    out.push_str("}\n");
94    out.push_str(r#"
95* { box-sizing: border-box; }
96html, body {
97    margin: 0;
98    padding: 0;
99    background: var(--color-bg);
100    color: var(--color-fg);
101    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
102    line-height: 1.5;
103}
104main {
105    max-width: 1100px;
106    margin: 0 auto;
107    padding: 2rem 1.5rem 4rem;
108}
109header.page {
110    border-bottom: 1px solid var(--color-border);
111    padding-bottom: 1.5rem;
112    margin-bottom: 2rem;
113}
114header.page h1 {
115    margin: 0 0 .25rem;
116    font-size: 1.6rem;
117    font-weight: 600;
118}
119header.page .subtitle {
120    color: var(--color-muted);
121    font-size: .95rem;
122}
123.verdict-badge {
124    display: inline-block;
125    padding: .35rem .9rem;
126    border-radius: 4px;
127    font-size: .8rem;
128    font-weight: 700;
129    letter-spacing: .08em;
130    text-transform: uppercase;
131    margin-top: .85rem;
132    color: #fff;
133}
134.verdict-pass  { background: var(--color-pass); }
135.verdict-fail  { background: var(--color-fail); }
136.verdict-warn  { background: var(--color-warn); color: #1a1a1a; }
137.verdict-skip  { background: var(--color-muted); }
138section { margin: 2rem 0; }
139section > h2 {
140    font-size: 1.1rem;
141    font-weight: 600;
142    margin: 0 0 .9rem;
143    padding-bottom: .35rem;
144    border-bottom: 1px solid var(--color-border);
145}
146.counts {
147    display: flex;
148    flex-wrap: wrap;
149    gap: 1rem;
150    margin: .5rem 0 1.2rem;
151}
152.count {
153    flex: 1 1 6rem;
154    background: var(--color-surface);
155    border: 1px solid var(--color-border);
156    border-left: 4px solid var(--color-muted);
157    padding: .7rem 1rem;
158    border-radius: 3px;
159}
160.count.pass { border-left-color: var(--color-pass); }
161.count.fail { border-left-color: var(--color-fail); }
162.count.warn { border-left-color: var(--color-warn); }
163.count.skip { border-left-color: var(--color-muted); }
164.count .value { font-size: 1.4rem; font-weight: 700; display: block; }
165.count .label { font-size: .8rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: .06em; }
166svg.bar-chart, svg.histogram {
167    width: 100%;
168    height: auto;
169    background: var(--color-surface);
170    border: 1px solid var(--color-border);
171    border-radius: 3px;
172    padding: .25rem;
173}
174details.producer {
175    background: var(--color-surface);
176    border: 1px solid var(--color-border);
177    border-radius: 3px;
178    margin: .5rem 0;
179    padding: 0;
180}
181details.producer > summary {
182    cursor: pointer;
183    padding: .75rem 1rem;
184    font-weight: 600;
185    user-select: none;
186    list-style: none;
187}
188details.producer > summary::-webkit-details-marker { display: none; }
189details.producer > summary::before {
190    content: "▸ ";
191    color: var(--color-muted);
192    font-weight: 400;
193    transition: transform .15s ease;
194    display: inline-block;
195    width: 1em;
196}
197details.producer[open] > summary::before { content: "▾ "; }
198details.producer summary .producer-meta {
199    color: var(--color-muted);
200    font-weight: 400;
201    margin-left: .5rem;
202    font-size: .9rem;
203}
204table.checks {
205    width: 100%;
206    border-collapse: collapse;
207    font-size: .9rem;
208}
209table.checks th, table.checks td {
210    text-align: left;
211    padding: .45rem .75rem;
212    border-top: 1px solid var(--color-border);
213    vertical-align: top;
214}
215table.checks th {
216    background: var(--color-bg);
217    color: var(--color-muted);
218    text-transform: uppercase;
219    font-size: .72rem;
220    letter-spacing: .06em;
221    font-weight: 600;
222}
223table.checks td.verdict {
224    font-weight: 600;
225    white-space: nowrap;
226    width: 6ch;
227}
228table.checks td.verdict.pass { color: var(--color-pass); }
229table.checks td.verdict.fail { color: var(--color-fail); }
230table.checks td.verdict.warn { color: var(--color-warn); }
231table.checks td.verdict.skip { color: var(--color-muted); }
232table.checks td.duration { color: var(--color-muted); white-space: nowrap; width: 8ch; text-align: right; }
233table.checks td.severity { color: var(--color-muted); white-space: nowrap; }
234table.checks td.detail { color: var(--color-fg); }
235table.checks td.detail .empty { color: var(--color-muted); }
236footer.page {
237    margin-top: 3rem;
238    padding-top: 1rem;
239    border-top: 1px solid var(--color-border);
240    color: var(--color-muted);
241    font-size: .85rem;
242    text-align: center;
243}
244@media print {
245    body { background: #fff; color: #000; }
246    main { max-width: none; padding: 0; }
247    details.producer { border: 1px solid #ccc; }
248    details.producer[open] > summary::before, details.producer > summary::before { content: ""; }
249    details.producer > div.producer-body { display: block !important; }
250}
251"#);
252    out.push_str("</style>\n");
253}
254
255fn write_header(out: &mut String, multi: &MultiReport, overall: Verdict, total: usize) {
256    out.push_str("<main>\n<header class=\"page\">\n");
257    out.push_str("    <h1>");
258    write_html_text(out, &multi.subject);
259    out.push_str(" <span class=\"version\">v");
260    write_html_text(out, &multi.subject_version);
261    out.push_str("</span></h1>\n");
262
263    out.push_str("    <div class=\"subtitle\">started ");
264    write_html_text(out, &multi.started_at.to_rfc3339());
265    if let Some(end) = multi.finished_at {
266        out.push_str(" · finished ");
267        write_html_text(out, &end.to_rfc3339());
268    }
269    write!(
270        out,
271        " · {} check{} across {} producer{}",
272        total,
273        if total == 1 { "" } else { "s" },
274        multi.reports.len(),
275        if multi.reports.len() == 1 { "" } else { "s" }
276    )
277    .unwrap();
278    out.push_str("</div>\n");
279
280    let verdict_class = match overall {
281        Verdict::Pass => "verdict-pass",
282        Verdict::Fail => "verdict-fail",
283        Verdict::Warn => "verdict-warn",
284        Verdict::Skip => "verdict-skip",
285    };
286    write!(out, "    <div class=\"verdict-badge {}\">", verdict_class).unwrap();
287    out.push_str(verdict_label(overall));
288    out.push_str("</div>\n</header>\n");
289}
290
291fn verdict_label(v: Verdict) -> &'static str {
292    match v {
293        Verdict::Pass => "Pass",
294        Verdict::Fail => "Fail",
295        Verdict::Warn => "Warn",
296        Verdict::Skip => "Skip",
297    }
298}
299
300fn write_summary_section(out: &mut String, pass: usize, fail: usize, warn: usize, skip: usize) {
301    out.push_str("<section>\n    <h2>Summary</h2>\n    <div class=\"counts\">\n");
302    for (label, count, class) in [
303        ("Pass", pass, "pass"),
304        ("Fail", fail, "fail"),
305        ("Warn", warn, "warn"),
306        ("Skip", skip, "skip"),
307    ] {
308        write!(
309            out,
310            "        <div class=\"count {}\"><span class=\"value\">{}</span><span class=\"label\">{}</span></div>\n",
311            class, count, label
312        )
313        .unwrap();
314    }
315    out.push_str("    </div>\n");
316    write_bar_chart(out, pass, fail, warn, skip);
317    out.push_str("</section>\n");
318}
319
320fn write_bar_chart(out: &mut String, pass: usize, fail: usize, warn: usize, skip: usize) {
321    let total = pass + fail + warn + skip;
322    if total == 0 {
323        return;
324    }
325    // Stacked horizontal bar: pass | fail | warn | skip
326    let width = 1000u32;
327    let height = 40u32;
328    let mut x = 0u32;
329    let mk_seg = |count: usize, color_var: &str| -> Option<(u32, String)> {
330        if count == 0 {
331            return None;
332        }
333        let w = ((count as f64 / total as f64) * width as f64).round() as u32;
334        if w == 0 {
335            return None;
336        }
337        Some((w, color_var.into()))
338    };
339    let segments: Vec<(usize, &'static str, &'static str)> = vec![
340        (pass, "var(--color-pass)", "Pass"),
341        (fail, "var(--color-fail)", "Fail"),
342        (warn, "var(--color-warn)", "Warn"),
343        (skip, "var(--color-muted)", "Skip"),
344    ];
345
346    write!(
347        out,
348        "    <svg class=\"bar-chart\" viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" aria-label=\"Verdict distribution: {} pass, {} fail, {} warn, {} skip\">\n",
349        width, height, pass, fail, warn, skip
350    )
351    .unwrap();
352    for (count, color, label) in segments {
353        if let Some((w, _)) = mk_seg(count, color) {
354            write!(
355                out,
356                "        <rect x=\"{}\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"><title>{} — {}</title></rect>\n",
357                x, w, height, color, label, count
358            )
359            .unwrap();
360            x = x.saturating_add(w);
361        }
362    }
363    // Fill any rounding gap on the right with the last non-zero color.
364    if x < width {
365        // pick fallback color: muted
366        write!(
367            out,
368            "        <rect x=\"{}\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"var(--color-muted)\"/>\n",
369            x,
370            width - x,
371            height
372        )
373        .unwrap();
374    }
375    out.push_str("    </svg>\n");
376}
377
378fn write_duration_section(out: &mut String, multi: &MultiReport) {
379    let mut durations: Vec<u64> = multi
380        .reports
381        .iter()
382        .flat_map(|r| r.checks.iter().filter_map(|c| c.duration_ms))
383        .collect();
384    if durations.is_empty() {
385        return;
386    }
387    durations.sort_unstable();
388    let min = *durations.first().unwrap();
389    let max = *durations.last().unwrap();
390    let count = durations.len();
391
392    out.push_str("<section>\n    <h2>Duration distribution</h2>\n");
393    write!(
394        out,
395        "    <div class=\"subtitle\" style=\"color:var(--color-muted);font-size:.85rem;margin-bottom:.5rem\">{} sample{}, {} ms min, {} ms max</div>\n",
396        count,
397        if count == 1 { "" } else { "s" },
398        min,
399        max
400    )
401    .unwrap();
402    write_histogram(out, &durations, min, max);
403    out.push_str("</section>\n");
404}
405
406fn write_histogram(out: &mut String, sorted_durations: &[u64], min: u64, max: u64) {
407    let buckets: usize = 10;
408    let mut bins = vec![0usize; buckets];
409    if min == max {
410        bins[0] = sorted_durations.len();
411    } else {
412        let range = max - min;
413        for &d in sorted_durations {
414            let idx = ((d - min) as f64 / range as f64 * buckets as f64) as usize;
415            let idx = idx.min(buckets - 1);
416            bins[idx] += 1;
417        }
418    }
419    let max_bin = bins.iter().copied().max().unwrap_or(1).max(1);
420    let width = 1000u32;
421    let height = 160u32;
422    let bar_w = width / buckets as u32;
423
424    write!(
425        out,
426        "    <svg class=\"histogram\" viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" aria-label=\"Histogram of check durations\">\n",
427        width, height
428    )
429    .unwrap();
430
431    for (i, &bin) in bins.iter().enumerate() {
432        let x = i as u32 * bar_w;
433        let h = ((bin as f64 / max_bin as f64) * (height - 10) as f64).round() as u32;
434        let y = height - h;
435        let bucket_lo = if max == min {
436            min
437        } else {
438            min + (max - min) * i as u64 / buckets as u64
439        };
440        let bucket_hi = if max == min {
441            max
442        } else {
443            min + (max - min) * (i as u64 + 1) / buckets as u64
444        };
445        write!(
446            out,
447            "        <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"var(--color-accent)\"><title>{}\u{2013}{} ms — {} sample{}</title></rect>\n",
448            x + 1,
449            y,
450            bar_w.saturating_sub(2),
451            h,
452            bucket_lo,
453            bucket_hi,
454            bin,
455            if bin == 1 { "" } else { "s" }
456        )
457        .unwrap();
458    }
459    out.push_str("    </svg>\n");
460}
461
462fn write_producers_section(out: &mut String, multi: &MultiReport) {
463    out.push_str("<section>\n    <h2>Per-producer reports</h2>\n");
464    if multi.reports.is_empty() {
465        out.push_str("    <p style=\"color:var(--color-muted)\">No reports.</p>\n");
466        out.push_str("</section>\n");
467        return;
468    }
469    for report in &multi.reports {
470        write_producer(out, report);
471    }
472    out.push_str("</section>\n");
473}
474
475fn write_producer(out: &mut String, report: &Report) {
476    let (pass, fail, warn, skip) = report.verdict_counts();
477    let total = pass + fail + warn + skip;
478    let producer_name = report.producer.as_deref().unwrap_or("(unnamed producer)");
479    // Open <details> by default if the producer has failures or warnings.
480    let open_attr = if fail > 0 || warn > 0 { " open" } else { "" };
481    write!(out, "    <details class=\"producer\"{}>\n", open_attr).unwrap();
482    out.push_str("        <summary>");
483    write_html_text(out, producer_name);
484    write!(
485        out,
486        "<span class=\"producer-meta\">— {} pass · {} fail · {} warn · {} skip · {} total</span>",
487        pass, fail, warn, skip, total
488    )
489    .unwrap();
490    out.push_str("</summary>\n");
491    out.push_str("        <div class=\"producer-body\">\n");
492    if report.checks.is_empty() {
493        out.push_str("            <p style=\"padding:0 1rem 1rem;color:var(--color-muted)\">No checks.</p>\n");
494    } else {
495        write_check_table(out, &report.checks);
496    }
497    out.push_str("        </div>\n    </details>\n");
498}
499
500fn write_check_table(out: &mut String, checks: &[CheckResult]) {
501    out.push_str("            <table class=\"checks\">\n");
502    out.push_str("                <thead><tr><th>Check</th><th>Verdict</th><th>Severity</th><th>Duration</th><th>Detail</th></tr></thead>\n");
503    out.push_str("                <tbody>\n");
504    for c in checks {
505        write_check_row(out, c);
506    }
507    out.push_str("                </tbody>\n            </table>\n");
508}
509
510fn write_check_row(out: &mut String, c: &CheckResult) {
511    let verdict_class = match c.verdict {
512        Verdict::Pass => "pass",
513        Verdict::Fail => "fail",
514        Verdict::Warn => "warn",
515        Verdict::Skip => "skip",
516    };
517    out.push_str("                    <tr>\n");
518    out.push_str("                        <td>");
519    write_html_text(out, &c.name);
520    out.push_str("</td>\n");
521    write!(
522        out,
523        "                        <td class=\"verdict {}\">{}</td>\n",
524        verdict_class,
525        verdict_label(c.verdict)
526    )
527    .unwrap();
528    out.push_str("                        <td class=\"severity\">");
529    match c.severity {
530        Some(s) => out.push_str(severity_label(s)),
531        None => out.push('—'),
532    }
533    out.push_str("</td>\n                        <td class=\"duration\">");
534    match c.duration_ms {
535        Some(ms) => {
536            write!(out, "{} ms", ms).unwrap();
537        }
538        None => out.push('—'),
539    }
540    out.push_str("</td>\n                        <td class=\"detail\">");
541    match &c.detail {
542        Some(d) => write_html_text(out, d),
543        None => out.push_str("<span class=\"empty\">—</span>"),
544    }
545    out.push_str("</td>\n                    </tr>\n");
546}
547
548fn severity_label(s: Severity) -> &'static str {
549    match s {
550        Severity::Info => "Info",
551        Severity::Warning => "Warning",
552        Severity::Error => "Error",
553        Severity::Critical => "Critical",
554    }
555}
556
557fn write_footer(out: &mut String) {
558    out.push_str("<footer class=\"page\">");
559    write_html_text(out, brand::FOOTER);
560    out.push_str("</footer>\n</main>\n");
561}
562
563fn write_html_text(out: &mut String, s: &str) {
564    for ch in s.chars() {
565        match ch {
566            '&' => out.push_str("&amp;"),
567            '<' => out.push_str("&lt;"),
568            '>' => out.push_str("&gt;"),
569            '"' => out.push_str("&quot;"),
570            '\'' => out.push_str("&#39;"),
571            c => out.push(c),
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use chrono::TimeZone;
580    use dev_report::Evidence;
581
582    fn frozen_multi() -> MultiReport {
583        let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, 0).unwrap();
584        let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
585        bench.set_started_at(t0);
586        let mut c1 = CheckResult::pass("hot_path")
587            .with_duration_ms(120)
588            .with_evidence(Evidence::numeric("mean_ns", 1234.5));
589        c1.at = t0;
590        let mut c2 = CheckResult::pass("cold_path").with_duration_ms(45);
591        c2.at = t0;
592        bench.push(c1);
593        bench.push(c2);
594        bench.set_finished_at(Some(t0));
595
596        let mut chaos = Report::new("crate", "0.1.0").with_producer("dev-chaos");
597        chaos.set_started_at(t0);
598        let mut c3 = CheckResult::fail("recover", Severity::Error)
599            .with_detail("service did not recover within 5s")
600            .with_duration_ms(5_001);
601        c3.at = t0;
602        let mut c4 = CheckResult::warn("flaky::retry", Severity::Warning)
603            .with_detail("3rd retry of 5 was slow");
604        c4.at = t0;
605        let mut c5 = CheckResult::skip("network::flaky").with_detail("no net in sandbox");
606        c5.at = t0;
607        chaos.push(c3);
608        chaos.push(c4);
609        chaos.push(c5);
610        chaos.set_finished_at(Some(t0));
611
612        let mut multi = MultiReport::new("crate", "0.1.0");
613        multi.started_at = t0;
614        multi.push(bench);
615        multi.push(chaos);
616        multi.finished_at = Some(t0);
617        multi
618    }
619
620    #[test]
621    fn output_is_doctype_html() {
622        let html = multi_report_to_html(&frozen_multi());
623        assert!(html.starts_with("<!DOCTYPE html>"));
624        assert!(html.contains("<html lang=\"en\""));
625        assert!(html.contains("</html>"));
626    }
627
628    #[test]
629    fn output_contains_subject_and_version() {
630        let html = multi_report_to_html(&frozen_multi());
631        assert!(html.contains(">crate"));
632        assert!(html.contains("v0.1.0"));
633    }
634
635    #[test]
636    fn output_contains_verdict_badge_for_overall_fail() {
637        let html = multi_report_to_html(&frozen_multi());
638        assert!(html.contains("verdict-badge verdict-fail"));
639        assert!(html.contains(">Fail<"));
640    }
641
642    #[test]
643    fn output_contains_one_details_per_producer() {
644        let html = multi_report_to_html(&frozen_multi());
645        let n = html.matches("<details class=\"producer\"").count();
646        assert_eq!(n, 2);
647        assert!(html.contains("dev-bench"));
648        assert!(html.contains("dev-chaos"));
649    }
650
651    #[test]
652    fn output_is_deterministic() {
653        let multi = frozen_multi();
654        let a = multi_report_to_html(&multi);
655        let b = multi_report_to_html(&multi);
656        assert_eq!(a, b);
657    }
658
659    #[test]
660    fn output_escapes_special_chars() {
661        let mut r = Report::new("crate<&>", "0.1.0").with_producer("dev<bad>");
662        r.push(CheckResult::fail("name<&>\"'", Severity::Error).with_detail("oh <bad> & \"x\""));
663        let mut m = MultiReport::new("crate<&>", "0.1.0");
664        m.push(r);
665        let html = multi_report_to_html(&m);
666        assert!(html.contains("crate&lt;&amp;&gt;"));
667        assert!(html.contains("name&lt;&amp;&gt;&quot;&#39;"));
668        assert!(html.contains("oh &lt;bad&gt; &amp; &quot;x&quot;"));
669        assert!(!html.contains("crate<&>"));
670    }
671
672    #[test]
673    fn empty_multi_renders_without_panic() {
674        let m = MultiReport::new("empty", "0.0.0");
675        let html = multi_report_to_html(&m);
676        assert!(html.contains("No reports."));
677        assert!(html.contains("verdict-skip"));
678    }
679
680    #[test]
681    fn duration_section_present_when_any_duration() {
682        let html = multi_report_to_html(&frozen_multi());
683        assert!(html.contains("Duration distribution"));
684        assert!(html.contains("<svg class=\"histogram\""));
685    }
686
687    #[test]
688    fn duration_section_absent_when_no_durations() {
689        let mut r = Report::new("c", "0.1.0").with_producer("p");
690        r.push(CheckResult::pass("a"));
691        let mut m = MultiReport::new("c", "0.1.0");
692        m.push(r);
693        let html = multi_report_to_html(&m);
694        assert!(!html.contains("Duration distribution"));
695        assert!(!html.contains("<svg class=\"histogram\""));
696    }
697
698    #[test]
699    fn producer_with_failures_is_open_by_default() {
700        let html = multi_report_to_html(&frozen_multi());
701        // dev-chaos has fail+warn; should be open. dev-bench is all pass; closed.
702        assert!(html.contains("<details class=\"producer\" open>"));
703        let open_count = html.matches("<details class=\"producer\" open>").count();
704        let closed_count = html.matches("<details class=\"producer\">").count();
705        assert_eq!(open_count, 1);
706        assert_eq!(closed_count, 1);
707    }
708
709    #[test]
710    fn no_external_resources() {
711        let html = multi_report_to_html(&frozen_multi());
712        assert!(!html.contains("http://"));
713        // Allow https://www.w3.org for SVG namespace if present; we don't use it explicitly.
714        // Disallow external <script>, <link>, <img>.
715        assert!(!html.contains("<script src="));
716        assert!(!html.contains("<link rel="));
717        assert!(!html.contains("<img "));
718    }
719
720    #[test]
721    fn uses_css_custom_properties_for_brand_colors() {
722        let html = multi_report_to_html(&frozen_multi());
723        assert!(html.contains("--color-accent:"));
724        assert!(html.contains("--color-pass:"));
725        assert!(html.contains("--color-fail:"));
726        assert!(html.contains("--color-warn:"));
727        assert!(html.contains("var(--color-accent)"));
728    }
729
730    #[test]
731    fn footer_contains_brand_footer_constant() {
732        let html = multi_report_to_html(&frozen_multi());
733        assert!(html.contains(brand::FOOTER));
734    }
735
736    #[test]
737    fn output_size_is_reasonable() {
738        // Smoke check: shouldn't be larger than ~64 KiB for a small report.
739        let html = multi_report_to_html(&frozen_multi());
740        assert!(html.len() < 64 * 1024, "got {} bytes", html.len());
741        assert!(
742            html.len() > 1_000,
743            "got {} bytes (likely truncated)",
744            html.len()
745        );
746    }
747}