OXYGENHUB

Coverage / crates/hub/src/web/reports.rs 88.5%

323 of 365 lines run, 66 of 130 functions · run #502 on main · run missed

1
//! Reports pages (oxygenhub#127, docs/reports.md): the Reports tab, a
2
//! report's page with its metrics across runs and its own page in a
3
//! sandboxed frame, and the files that frame reads.
4
5
use super::{filters, lookup, Error, Page, RepoShell, Shell};
6
use crate::auth::Viewer;
7
use crate::reports::{self, Metric, Report};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Path, Query, State};
11
use axum::http::{header, HeaderValue, StatusCode};
12
use axum::response::{IntoResponse, Response};
13
use serde::Deserialize;
14
15
/// Runs a report's page reads for its sparklines and run list.
16
const HISTORY: i64 = 60;
17
18
#[derive(Deserialize, Default)]
19
pub struct ReportsQuery {
20
    /// Only this bookmark's reports.
21
    bookmark: Option<String>,
22
    /// One report, by id.
23
    id: Option<i64>,
24
    /// Another run of the same report to compare with, by id (#127, RP4).
25
    compare: Option<i64>,
26
    /// Playing: seconds per run, carried from stop to stop.
27
    play: Option<u32>,
28
}
29
30
/// A stop on the timeline: one run of the report on its bookmark.
31
pub struct Stop {
32
    pub id: i64,
33
    pub href: String,
34
    pub label: String,
35
    pub tip: String,
36
    pub current: bool,
37
    pub compared: bool,
38
}
39
40
/// A metric shown against another run.
41
pub struct Compared {
42
    pub key: String,
43
    pub here: String,
44
    pub there: String,
45
    pub unit: String,
46
    /// here − there, `+1.5`; empty when equal or missing.
47
    pub delta: String,
48
    pub trend: &'static str,
49
}
50
51
/// A metric as shown: its value, and its change against the run before.
52
pub struct MetricView {
53
    pub key: String,
54
    pub value: String,
55
    pub unit: String,
56
    /// `+0.4`, empty when there is no run before or no change.
57
    pub delta: String,
58
    /// `better`, `worse` or `same`, by the metric's `better`.
59
    pub trend: &'static str,
60
    /// Its threshold, as words, when it has one.
61
    pub threshold: String,
62
    /// Whether it crosses its threshold.
63
    pub failing: bool,
64
    /// An inline SVG of its values across runs, oldest first.
65
    pub spark: String,
66
}
67
68
/// A report on the Reports tab.
69
pub struct Item {
70
    pub report: Report,
71
    pub metrics: Vec<MetricView>,
72
    /// `good`, `bad` or `neutral`: its thresholds met, one crossed, or none.
73
    pub health: &'static str,
74
    /// What the badge says: "2 thresholds met", "1 over its threshold".
75
    pub badge: String,
76
}
77
78
/// The Coverage or Tests entry on the Reports tab: its latest figure.
79
pub struct Builtin {
80
    pub big: String,
81
    pub sub: String,
82
    pub health: &'static str,
83
    pub when_ms: i64,
84
    pub bookmark: String,
85
}
86
87
/// The health and badge of a report from its metrics' thresholds.
881×
fn health_of(metrics: &[MetricView]) -> (&'static str, String) {
891×
    let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count();
901×
    let failing = metrics.iter().filter(|m| m.failing).count();
911×
    match (with, failing) {
920×
        (0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })),
931×
        (_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })),
940×
        (_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })),
95
    }
961×
}
97
98
#[derive(Template)]
99
#[template(path = "reports.html")]
100
pub struct ReportsPage {
101
    shell: Shell,
102
    repo: RepoShell,
103
    bookmark: String,
104
    items: Vec<Item>,
105
    coverage: Option<Builtin>,
106
    tests: Option<Builtin>,
107
}
108
109
#[derive(Template)]
110
#[template(path = "report.html")]
111
pub struct ReportPage {
112
    shell: Shell,
113
    repo: RepoShell,
114
    report: Report,
115
    bookmarks: Vec<String>,
116
    metrics: Vec<MetricView>,
117
    context: Vec<(String, String)>,
118
    /// Newest first, the one shown among them.
119
    runs: Vec<Report>,
120
    /// Where the frame loads the report's page from, when it has one.
121
    frame: Option<String>,
122
    files_base: String,
123
    /// Oldest first, the run shown marked.
124
    timeline: Vec<Stop>,
125
    /// The run compared with, its metrics against this one's, and its frame.
126
    compare: Option<(Report, Vec<Compared>, Option<String>)>,
127
    /// Seconds per run while playing.
128
    play: Option<u32>,
129
    /// The runs, oldest first, as JSON for the frame interface
130
    /// (`report.run()`, `report.history()`).
131
    runs_json: String,
132
}
133
134
/// A number as a person would write it: no trailing zeros.
13519×
fn num(v: f64) -> String {
13619×
    if v.fract() == 0.0 && v.abs() < 1e15 {
13711×
        format!("{v:.0}")
138
    } else {
1398×
        let s = format!("{v:.3}");
1408×
        s.trim_end_matches('0').trim_end_matches('.').to_string()
141
    }
14219×
}
143
1449×
fn spark(values: &[f64]) -> String {
1459×
    if values.len() < 2 {
1467×
        return String::new();
1472×
    }
1482×
    let (w, h) = (120.0, 28.0);
1492×
    let lo = values.iter().cloned().fold(f64::INFINITY, f64::min);
1502×
    let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1512×
    let span = if hi > lo { hi - lo } else { 1.0 };
1522×
    let n = values.len();
1532×
    let pts: Vec<String> = values
1542×
        .iter()
1552×
        .enumerate()
1565×
        .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span))
1572×
        .collect();
1582×
    let last = pts.last().cloned().unwrap_or_default();
1592×
    let (lx, ly) = last.split_once(',').unwrap_or(("0", "0"));
1602×
    format!(
161
        r#"<svg class="rep-spark" viewBox="0 0 {w} {h}" width="{w}" height="{h}" aria-hidden="true"><polyline points="{}"/><circle cx="{lx}" cy="{ly}" r="2.5"/></svg>"#,
1622×
        pts.join(" ")
163
    )
1649×
}
165
166
/// The metrics of `now` against `before`, with each one's values across
167
/// `series` (oldest first) for a sparkline.
1687×
fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> {
1697×
    now.iter()
1707×
        .map(|m| {
1717×
            let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value);
1727×
            let (delta, trend) = match prev {
1732×
                Some(p) if (m.value - p).abs() > f64::EPSILON => {
1742×
                    let up = m.value > p;
1752×
                    let trend = match m.better.as_str() {
1762×
                        "higher" if up => "better",
1770×
                        "higher" => "worse",
1782×
                        "lower" if up => "worse",
1790×
                        "lower" => "better",
1800×
                        _ => "same",
181
                    };
1822×
                    (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend)
183
                }
1845×
                _ => (String::new(), "same"),
185
            };
1867×
            let mut threshold = Vec::new();
1877×
            if let Some(a) = m.fail_above {
1886×
                threshold.push(format!("fails above {}", num(a)));
1896×
            }
1907×
            if let Some(b) = m.fail_below {
1910×
                threshold.push(format!("fails below {}", num(b)));
1927×
            }
1937×
            let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b);
1947×
            let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect();
1957×
            MetricView {
1967×
                key: m.key.clone(),
1977×
                value: num(m.value),
1987×
                unit: m.unit.clone(),
1997×
                delta,
2007×
                trend,
2017×
                threshold: threshold.join(", "),
2027×
                failing,
2037×
                spark: spark(&values),
2047×
            }
2057×
        })
2067×
        .collect()
2077×
}
208
209
/// The metrics of this run against another's, every key either has.
2101×
fn compared(here: &[Metric], there: &[Metric]) -> Vec<Compared> {
2112×
    let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect();
2121×
    keys.sort();
2131×
    keys.dedup();
2141×
    keys.into_iter()
2151×
        .map(|k| {
2161×
            let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k));
2171×
            let (delta, trend) = match (a, b) {
2181×
                (Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => {
2191×
                    let up = a.value > b.value;
2201×
                    let trend = match a.better.as_str() {
2211×
                        "higher" if up => "better",
2220×
                        "higher" => "worse",
2231×
                        "lower" if up => "worse",
2241×
                        "lower" => "better",
2250×
                        _ => "same",
226
                    };
2271×
                    (format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend)
228
                }
2290×
                _ => (String::new(), "same"),
230
            };
231
            Compared {
2321×
                key: k.to_string(),
2331×
                here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()),
2341×
                there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()),
2351×
                unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(),
2361×
                delta,
2371×
                trend,
238
            }
2391×
        })
2401×
        .collect()
2411×
}
242
243
/// `GET /{owner}/{name}/reports`: the newest of each report, with its
244
/// metrics against the run before on its bookmark.
2452×
pub async fn overview(
2462×
    State(app): State<Shared>,
2472×
    viewer: Viewer,
2482×
    Path((owner, name)): Path<(String, String)>,
2492×
    Query(q): Query<ReportsQuery>,
2502×
) -> Result<Page<ReportsPage>, Error> {
2512×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer);
2522×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2532×
    let e = |e| Error::from_db(shell.clone(), e);
2542×
    let bookmark = q.bookmark.clone().filter(|b| !b.is_empty());
2552×
    let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?;
2562×
    let mut items = Vec::new();
2572×
    for r in latest {
2581×
        let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?;
2591×
        let now = reports::metrics(&app.db, r.id).await.map_err(e)?;
2601×
        let before = match history.get(1) {
2610×
            Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
2621×
            None => vec![],
263
        };
2641×
        let metrics = views(&now, &before, &[]);
2651×
        let (health, badge) = health_of(&metrics);
2661×
        items.push(Item { metrics, report: r, health, badge });
267
    }
2682×
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
269
    // Coverage and Tests: their latest, on the default bookmark when it has
270
    // one, else wherever was newest.
2714×
    let pick = |list: Vec<String>| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned());
2722×
    let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) {
2731×
        Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin {
2741×
            big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()),
2751×
            sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()),
2761×
            health: match c.percent() {
2771×
                Some(p) if p >= 75.0 => "good",
2780×
                Some(p) if p < 50.0 => "bad",
2790×
                _ => "neutral",
280
            },
2811×
            when_ms: c.created_ms,
2821×
            bookmark: c.bookmark.clone(),
2831×
        }),
2841×
        None => None,
285
    };
2862×
    let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) {
2870×
        Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin {
2880×
            big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) },
2890×
            sub: t.summary(),
2900×
            health: if t.broken() > 0 { "bad" } else { "good" },
2910×
            when_ms: t.created_ms,
2920×
            bookmark: t.bookmark.clone(),
2930×
        }),
2942×
        None => None,
295
    };
2962×
    Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items }))
2972×
}
298
299
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
300
/// bookmark unless `?id=` names a run, with its metrics across runs and
301
/// its page in a sandboxed frame.
3024×
pub async fn show(
3034×
    State(app): State<Shared>,
3044×
    viewer: Viewer,
3054×
    Path((owner, name, rname)): Path<(String, String, String)>,
3064×
    Query(q): Query<ReportsQuery>,
3074×
) -> Result<Page<ReportPage>, Error> {
3084×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer);
3094×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
3104×
    let e = |e| Error::from_db(shell.clone(), e);
3114×
    let report = match q.id {
3122×
        Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname),
313
        None => {
3142×
            let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
3152×
            let bookmark = q
3162×
                .bookmark
3172×
                .clone()
3182×
                .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned())
3192×
                .or_else(|| bookmarks.first().cloned());
3202×
            match bookmark {
3212×
                Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(),
3220×
                None => None,
323
            }
324
        }
325
    };
3264×
    let report = report.ok_or_else(|| Error::not_found(shell.clone()))?;
3274×
    let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
3284×
    let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?;
3294×
    let now = reports::metrics(&app.db, report.id).await.map_err(e)?;
330
    // The run before the one shown, and every run up to it for the lines.
33156×
    let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0);
3324×
    let before = match runs.get(at + 1) {
3331×
        Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
3343×
        None => vec![],
335
    };
3364×
    let mut series = Vec::new();
3375×
    for r in runs.iter().skip(at).rev() {
3385×
        series.push(reports::metrics(&app.db, r.id).await.map_err(e)?);
339
    }
34061×
    let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id));
3414×
    let files_base = base_of(report.id);
3424×
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
343114×
    let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into());
3444×
    let page = format!("/{owner}/{name}/reports/{rname}");
34557×
    let keep = |id: i64| {
34657×
        let mut url = format!("{page}?id={id}");
34757×
        if let Some(c) = q.compare.filter(|c| *c != id) {
3481×
            url.push_str(&format!("&compare={c}"));
34956×
        }
35057×
        if let Some(p) = q.play {
3512×
            url.push_str(&format!("&play={p}"));
35255×
        }
35357×
        url
35457×
    };
3554×
    let timeline: Vec<Stop> = runs
3564×
        .iter()
3574×
        .rev()
3584×
        .map(|r| Stop {
35957×
            id: r.id,
36057×
            href: keep(r.id),
36157×
            label: label(r),
36257×
            tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }),
36357×
            current: r.id == report.id,
36457×
            compared: Some(r.id) == q.compare,
36557×
        })
3664×
        .collect();
367
    // Another run to compare with (RP4): every metric, both values, the
368
    // difference; and its page beside this one's.
3694×
    let compare = match q.compare.filter(|c| *c != report.id) {
3701×
        Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) {
3711×
            Some(other) => {
3721×
                let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?;
3731×
                let rows = compared(&now, &theirs);
3741×
                let f =
3751×
                    reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id)));
3761×
                Some((other, rows, f))
377
            }
3780×
            None => None,
379
        },
3803×
        None => None,
381
    };
3824×
    let runs_json = serde_json::to_string(
3834×
        &runs
3844×
            .iter()
3854×
            .rev()
38657×
            .map(|r| serde_json::json!({ "id": r.id, "run": r.run_number, "bookmark": r.bookmark, "created_ms": r.created_ms, "summary": r.summary, "files": base_of(r.id), "current": r.id == report.id }))
3874×
            .collect::<Vec<_>>(),
388
    )
3894×
    .unwrap_or_default();
3904×
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
391
    Ok(Page(ReportPage {
3924×
        shell,
3934×
        repo,
3944×
        context: report.context_rows(),
3954×
        metrics: views(&now, &before, &series),
3964×
        bookmarks,
3974×
        runs,
3984×
        frame,
3994×
        files_base,
4004×
        report,
4014×
        timeline,
4024×
        compare,
4034×
        play: q.play.map(|p| p.clamp(1, 60)),
4044×
        runs_json,
405
    }))
4064×
}
407
408
/// `page` with the report frame script added (#127).
4093×
fn with_frame_script(page: Vec<u8>, version: &str) -> Vec<u8> {
4103×
    let tag = format!(r#"<script src="/static/report-frame.js?v={version}"></script>"#);
4113×
    let lower = page.to_ascii_lowercase();
41268×
    let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle);
4133×
    let at = find(b"</head>").or_else(|| find(b"<body")).unwrap_or(page.len());
4143×
    let mut out = Vec::with_capacity(page.len() + tag.len());
4153×
    out.extend_from_slice(&page[..at]);
4163×
    out.extend_from_slice(tag.as_bytes());
4173×
    out.extend_from_slice(&page[at..]);
4183×
    out
4193×
}
420
421
/// A file's type, by its extension.
4224×
fn content_type(path: &str) -> &'static str {
4234×
    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
4244×
        "html" | "htm" => "text/html; charset=utf-8",
4252×
        "js" | "mjs" => "text/javascript; charset=utf-8",
4262×
        "css" => "text/css; charset=utf-8",
4272×
        "json" => "application/json",
4282×
        "csv" => "text/csv; charset=utf-8",
4290×
        "svg" => "image/svg+xml",
4300×
        "png" => "image/png",
4310×
        "jpg" | "jpeg" => "image/jpeg",
4320×
        "gif" => "image/gif",
4330×
        "webp" => "image/webp",
4340×
        "woff2" => "font/woff2",
4350×
        "woff" => "font/woff",
4360×
        "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8",
4370×
        _ => "application/octet-stream",
438
    }
4394×
}
440
441
/// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file,
442
/// for its frame. The frame is sandboxed (scripts, no same origin), so it
443
/// carries no session: the MAC in the link is what lets it in. Every file
444
/// is served sandboxed too, with nothing beyond this hub to load or call,
445
/// and readable across origins, since the frame's own origin is opaque.
4464×
pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response {
4474×
    let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response();
4484×
    if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) {
4491×
        return not_found();
4503×
    }
4513×
    let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() };
4523×
    let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() };
4533×
    let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() };
4542×
    let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else {
4550×
        return not_found();
456
    };
4572×
    let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await {
4582×
        Ok(b) => b,
4590×
        Err(_) => return not_found(),
460
    };
461
    // A page gets the frame script, which sizes the frame to it and passes
462
    // the hub's theme on: before </head>, else <body>, else at the end.
4632×
    let bytes =
4642×
        if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes };
4652×
    let origin = app.config.base_url().trim_end_matches('/').to_string();
4662×
    let csp = format!(
467
        "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \
468
         style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \
469
         connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'"
470
    );
4712×
    let mut res = (StatusCode::OK, bytes).into_response();
4722×
    let h = res.headers_mut();
4732×
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path)));
4742×
    if let Ok(v) = HeaderValue::from_str(&csp) {
4752×
        h.insert(header::CONTENT_SECURITY_POLICY, v);
4762×
    }
4772×
    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
4782×
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
4792×
    h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
4802×
    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable"));
4812×
    res
4824×
}
483
484
#[cfg(test)]
485
mod tests {
486
    use super::*;
487
488
    #[test]
4891×
    fn metrics_say_which_way_they_moved() {
4901×
        let m = |v: f64, better: &str, above: Option<f64>| Metric {
4913×
            key: "frame".into(),
4923×
            value: v,
4933×
            unit: "ms".into(),
4943×
            better: better.into(),
4953×
            fail_above: above,
4963×
            fail_below: None,
4973×
        };
4981×
        let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0];
4991×
        assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false));
5001×
        assert_eq!(v.threshold, "fails above 20");
5011×
        let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0];
5021×
        assert!(v.failing && v.delta.is_empty());
5031×
        assert_eq!(num(16.40), "16.4");
5041×
        assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline"));
5051×
        assert!(spark(&[1.0]).is_empty());
5061×
        let page = with_frame_script(b"<!doctype html><html><HEAD><title>t</title></HEAD><body>x</body></html>".to_vec(), "v1");
5071×
        let page = String::from_utf8(page).unwrap();
5081×
        assert!(page.starts_with("<!doctype html>"), "the doctype stays first");
5091×
        assert!(page.contains(r#"<script src="/static/report-frame.js?v=v1"></script></HEAD>"#), "{page}");
5101×
        let bare = String::from_utf8(with_frame_script(b"<p>hi</p>".to_vec(), "v1")).unwrap();
5111×
        assert!(bare.ends_with("</script>"), "{bare}");
5121×
    }
513
}