Coverage page: charts and a livelier layout c987c86513df
A hero with the latest percentage and its change over a chart of every run on the bookmark (up to 200, each point naming its run and opening its report); a strip of facts (lines covered, hits per line, functions, branches, files, first report, badge with copyable Markdown); this run's changes with the files whose coverage moved most; files by coverage band; a chart of covered lines with the missed lines as a band above; recent runs with their change; a bookmark picker. Charts are server-drawn SVG in the theme's own colours, light and dark. Reports keep total hits for hits per line (migration 0046, not yet deployed). refs #113
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 15:54 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c987c86513df8e00d726ae32c83c2479e950290ab40e2228992888df7938d2fc- Depends on
- 42fa95540468 554ccacfc123 749d9a5fcd9f 93be9ce4653e b5668108487f d23464c0ac60
- Depended on by
- a7aa76ac52d7 5d90e0e045e2 40dd3f86379a c8df33a6829f 7b26e6118bc0 7fff30c4f5e2 36e5a9c15a84 7edaff01d506
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 41 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified CHANGELOG.md +1 −1
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | - | - **Coverage reports, built in (#113).** A Flow job's artifact named `coverage` in LCOV format is read when uploaded, and the job's log says what it found. A Coverage tab shows the latest percentage and its change, a trend over the last 30 runs, every file with a bar, and each file's lines marked run or missed, as Coveralls does. A badge is at `/OWNER/NAME/coverage/badge.svg`, and the run page shows each job's coverage. Totals match `cargo llvm-cov`'s own. |
|
| 7 | + | - **Coverage reports, built in (#113).** A Flow job's artifact named `coverage` in LCOV format is read when uploaded, and the job's log says what it found. A Coverage tab shows the latest percentage and its change over a chart of every run, a strip of facts (lines covered, hits per line, functions, branches, files, badge with its Markdown), what this run changed and the files whose coverage moved, how files spread over coverage bands, a chart of covered and missed lines, the recent runs, every file with a bar, and each file's lines marked run or missed, as Coveralls does. A badge is at `/OWNER/NAME/coverage/badge.svg`, and the run page shows each job's coverage. Totals match `cargo llvm-cov`'s own. |
|
| 8 | 8 | ||
| 9 | 9 | - **A wiki can start with a push.** Someone who may write to a repository can push the first pages of its wiki to `OWNER/NAME.wiki`, as well as writing them on the web: the hub makes the empty wiki when a writer first reaches that address, and the push gives it its id. Readers still find nothing there until it has pages. |
|
| 10 | 10 | ||
| … | 128 unchanged lines |
modified crates/hub/migrations/0046_coverage.sql +2 −0
| … | 19 unchanged lines |
||
| 20 | 20 | functions_hit INTEGER NOT NULL, |
|
| 21 | 21 | branches_found INTEGER NOT NULL, |
|
| 22 | 22 | branches_hit INTEGER NOT NULL, |
|
| 23 | + | -- Every run of every instrumented line, added up: hits per line. |
|
| 24 | + | hits INTEGER NOT NULL DEFAULT 0, |
|
| 23 | 25 | files INTEGER NOT NULL, |
|
| 24 | 26 | -- Files the report named that are not in the run's state. |
|
| 25 | 27 | dropped INTEGER NOT NULL, |
|
| … | 14 unchanged lines |
modified crates/hub/src/coverage.rs +27 −2
| … | 40 unchanged lines |
||
| 41 | 41 | pub fn lines_hit(&self) -> u64 { |
|
| 42 | 42 | self.declared_lines.map(|d| d.1).unwrap_or_else(|| self.lines.values().filter(|n| **n > 0).count() as u64) |
|
| 43 | 43 | } |
|
| 44 | + | ||
| 45 | + | /// Every run of every line, added up. |
|
| 46 | + | pub fn hits(&self) -> u64 { |
|
| 47 | + | self.lines.values().fold(0u64, |a, n| a.saturating_add(*n)) |
|
| 48 | + | } |
|
| 44 | 49 | } |
|
| 45 | 50 | ||
| 46 | 51 | /// A path as a report names it, relative to the repository: the Flow |
|
| … | 142 unchanged lines |
||
| 189 | 194 | pub functions_hit: i64, |
|
| 190 | 195 | pub branches_found: i64, |
|
| 191 | 196 | pub branches_hit: i64, |
|
| 197 | + | pub hits: i64, |
|
| 192 | 198 | pub files: i64, |
|
| 193 | 199 | pub dropped: i64, |
|
| 194 | 200 | pub created_ms: i64, |
|
| 195 | 201 | } |
|
| 196 | 202 | ||
| 197 | 203 | impl Report { |
|
| 204 | + | /// How often an instrumented line ran, on average. |
|
| 205 | + | pub fn hits_per_line(&self) -> Option<f64> { |
|
| 206 | + | (self.lines_found > 0).then(|| self.hits as f64 / self.lines_found as f64) |
|
| 207 | + | } |
|
| 208 | + | ||
| 198 | 209 | pub fn percent(&self) -> Option<f64> { |
|
| 199 | 210 | percent(self.lines_hit, self.lines_found) |
|
| 200 | 211 | } |
|
| … | 81 unchanged lines |
||
| 282 | 293 | sqlx::query("DELETE FROM coverage_reports WHERE job_id = ?").bind(o.job_id).execute(&mut *tx).await?; |
|
| 283 | 294 | let id: i64 = sqlx::query_scalar( |
|
| 284 | 295 | "INSERT INTO coverage_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format, \ |
|
| 285 | - | lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, files, dropped, created_ms) \ |
|
| 286 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lcov', ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 296 | + | lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms) \ |
|
| 297 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lcov', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 287 | 298 | ) |
|
| 288 | 299 | .bind(o.repo_id) |
|
| 289 | 300 | .bind(o.run_id) |
|
| … | 10 unchanged lines |
||
| 300 | 311 | .bind(sum(&|f| f.functions_hit)) |
|
| 301 | 312 | .bind(sum(&|f| f.branches_found)) |
|
| 302 | 313 | .bind(sum(&|f| f.branches_hit)) |
|
| 314 | + | .bind(kept.iter().fold(0u64, |a, f| a.saturating_add(f.hits())).min(i64::MAX as u64) as i64) |
|
| 303 | 315 | .bind(kept.len() as i64) |
|
| 304 | 316 | .bind((missing.len() + outside) as i64) |
|
| 305 | 317 | .bind(now_ms()) |
|
| … | 30 unchanged lines |
||
| 336 | 348 | .bind(repo_id) |
|
| 337 | 349 | .bind(bookmark) |
|
| 338 | 350 | .bind(limit) |
|
| 351 | + | .fetch_all(pool) |
|
| 352 | + | .await |
|
| 353 | + | } |
|
| 354 | + | ||
| 355 | + | /// When the repository's first report on any bookmark was kept. |
|
| 356 | + | pub async fn first_ms(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<i64>> { |
|
| 357 | + | sqlx::query_scalar("SELECT MIN(created_ms) FROM coverage_reports WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await |
|
| 358 | + | } |
|
| 359 | + | ||
| 360 | + | /// The bookmarks with reports, the most recent first. |
|
| 361 | + | pub async fn bookmarks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
|
| 362 | + | sqlx::query_scalar("SELECT bookmark FROM coverage_reports WHERE repo_id = ? GROUP BY bookmark ORDER BY MAX(created_ms) DESC") |
|
| 363 | + | .bind(repo_id) |
|
| 339 | 364 | .fetch_all(pool) |
|
| 340 | 365 | .await |
|
| 341 | 366 | } |
|
| … | 50 unchanged lines |
modified crates/hub/src/web/coverage.rs +269 −41
| … | 12 unchanged lines |
||
| 13 | 13 | use std::collections::BTreeMap; |
|
| 14 | 14 | use std::sync::{Arc, Mutex}; |
|
| 15 | 15 | ||
| 16 | - | /// Reports drawn on the trend. |
|
| 17 | - | const TREND: i64 = 30; |
|
| 16 | + | /// Reports the charts and the recent list draw on. |
|
| 17 | + | const HISTORY: i64 = 200; |
|
| 18 | + | /// Runs listed under Recent runs. |
|
| 19 | + | const RECENT: usize = 10; |
|
| 20 | + | /// Files listed as changed against the run before. |
|
| 21 | + | const CHANGED: usize = 12; |
|
| 18 | 22 | ||
| 19 | 23 | #[derive(Deserialize, Default)] |
|
| 20 | 24 | pub struct CoverageQuery { |
|
| … | 5 unchanged lines |
||
| 26 | 30 | sort: Option<String>, |
|
| 27 | 31 | } |
|
| 28 | 32 | ||
| 33 | + | /// A run in the Recent runs list. |
|
| 34 | + | pub struct RecentRow { |
|
| 35 | + | pub report: Report, |
|
| 36 | + | /// Against the report before it, in percentage points. |
|
| 37 | + | pub delta: Option<f64>, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// A file whose coverage moved against the run before. |
|
| 41 | + | pub struct Moved { |
|
| 42 | + | pub path: String, |
|
| 43 | + | pub before: Option<f64>, |
|
| 44 | + | pub after: Option<f64>, |
|
| 45 | + | pub delta: f64, |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// One band of the file distribution. |
|
| 49 | + | pub struct Band { |
|
| 50 | + | pub label: &'static str, |
|
| 51 | + | pub class: &'static str, |
|
| 52 | + | pub files: usize, |
|
| 53 | + | /// Of the widest band, for the bar's width. |
|
| 54 | + | pub width: f64, |
|
| 55 | + | } |
|
| 56 | + | ||
| 29 | 57 | #[derive(Template)] |
|
| 30 | 58 | #[template(path = "coverage.html")] |
|
| 31 | 59 | pub struct CoveragePage { |
|
| 32 | 60 | shell: Shell, |
|
| 33 | 61 | repo: RepoShell, |
|
| 34 | 62 | bookmark: String, |
|
| 63 | + | bookmarks: Vec<String>, |
|
| 35 | 64 | report: Option<Report>, |
|
| 36 | - | /// Against the report before it on the bookmark, in percentage points. |
|
| 65 | + | /// The report before this one on the bookmark. |
|
| 66 | + | previous: Option<Report>, |
|
| 37 | 67 | delta: Option<f64>, |
|
| 38 | 68 | files: Vec<FileRow>, |
|
| 39 | 69 | sort: String, |
|
| 40 | - | trend: String, |
|
| 70 | + | coverage_chart: String, |
|
| 71 | + | lines_chart: String, |
|
| 41 | 72 | runs: usize, |
|
| 73 | + | first_ms: Option<i64>, |
|
| 74 | + | recent: Vec<RecentRow>, |
|
| 75 | + | moved: Vec<Moved>, |
|
| 76 | + | /// Files that are new against the run before. |
|
| 77 | + | added: usize, |
|
| 78 | + | bands: Vec<Band>, |
|
| 42 | 79 | } |
|
| 43 | 80 | ||
| 44 | 81 | impl CoveragePage { |
|
| … | 1 unchanged lines |
||
| 46 | 83 | p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) |
|
| 47 | 84 | } |
|
| 48 | 85 | ||
| 49 | - | pub fn delta_text(&self) -> Option<String> { |
|
| 50 | - | self.delta.map(|d| if d.abs() < 0.05 { "unchanged".to_string() } else { format!("{d:+.1}") }) |
|
| 86 | + | pub fn pct_of(&self, p: &Option<f64>) -> String { |
|
| 87 | + | self.pct(*p) |
|
| 51 | 88 | } |
|
| 52 | 89 | ||
| 90 | + | pub fn signed(&self, d: &f64) -> String { |
|
| 91 | + | if d.abs() < 0.05 { |
|
| 92 | + | "±0.0".to_string() |
|
| 93 | + | } else { |
|
| 94 | + | format!("{d:+.1}") |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | pub fn delta_class(&self, d: &f64) -> &'static str { |
|
| 99 | + | if *d >= 0.05 { |
|
| 100 | + | "up" |
|
| 101 | + | } else if *d <= -0.05 { |
|
| 102 | + | "down" |
|
| 103 | + | } else { |
|
| 104 | + | "same" |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | ||
| 53 | 108 | pub fn class_of(&self, p: Option<f64>) -> &'static str { |
|
| 54 | 109 | level(p) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | pub fn hits(&self, r: &Report) -> String { |
|
| 113 | + | r.hits_per_line().map(|h| if h >= 100.0 { compact(h) } else { format!("{h:.1}") }).unwrap_or_else(|| "–".into()) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | pub fn lines_delta(&self) -> Option<(i64, i64)> { |
|
| 117 | + | let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?); |
|
| 118 | + | Some((r.lines_found - p.lines_found, r.lines_hit - p.lines_hit)) |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | /// A count, short: 9.4k, 147.7k, 2.3M. |
|
| 123 | + | fn compact(v: f64) -> String { |
|
| 124 | + | match v { |
|
| 125 | + | v if v >= 1e9 => format!("{:.1}G", v / 1e9), |
|
| 126 | + | v if v >= 1e6 => format!("{:.1}M", v / 1e6), |
|
| 127 | + | v if v >= 1e3 => format!("{:.1}k", v / 1e3), |
|
| 128 | + | v => format!("{v:.0}"), |
|
| 55 | 129 | } |
|
| 56 | 130 | } |
|
| 57 | 131 | ||
| … | 8 unchanged lines |
||
| 66 | 140 | } |
|
| 67 | 141 | } |
|
| 68 | 142 | ||
| 69 | - | /// The trend as an SVG line chart, oldest on the left. |
|
| 70 | - | fn trend_svg(reports: &[Report], url: &str) -> String { |
|
| 71 | - | let points: Vec<(f64, &Report)> = reports.iter().rev().filter_map(|r| r.percent().map(|p| (p, r))).collect(); |
|
| 72 | - | if points.len() < 2 { |
|
| 143 | + | /// A line of values across runs, for [`chart`]. |
|
| 144 | + | struct Series<'a> { |
|
| 145 | + | values: Vec<f64>, |
|
| 146 | + | class: &'a str, |
|
| 147 | + | /// Shade down to these values instead of to the axis: a band. |
|
| 148 | + | floor: Option<Vec<f64>>, |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /// An SVG chart of one or more series over runs, oldest on the left, with |
|
| 152 | + | /// a light grid and labels, and a column per run that names it on hover |
|
| 153 | + | /// and links to its report. `fmt` labels the y axis. |
|
| 154 | + | #[allow(clippy::too_many_arguments)] // one chart, described in full by its caller |
|
| 155 | + | fn chart( |
|
| 156 | + | label: &str, |
|
| 157 | + | runs: &[&Report], |
|
| 158 | + | series: &[Series], |
|
| 159 | + | lo: f64, |
|
| 160 | + | hi: f64, |
|
| 161 | + | fmt: &dyn Fn(f64) -> String, |
|
| 162 | + | url: &str, |
|
| 163 | + | tip: &dyn Fn(&Report) -> String, |
|
| 164 | + | ) -> String { |
|
| 165 | + | let (w, h) = (800.0, 180.0); |
|
| 166 | + | let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0); |
|
| 167 | + | let (pw, ph) = (w - left - right, h - top - bottom); |
|
| 168 | + | let n = runs.len(); |
|
| 169 | + | if n == 0 { |
|
| 73 | 170 | return String::new(); |
|
| 171 | + | } |
|
| 172 | + | let x = |i: usize| if n == 1 { left + pw / 2.0 } else { left + pw * i as f64 / (n - 1) as f64 }; |
|
| 173 | + | let span = if hi > lo { hi - lo } else { 1.0 }; |
|
| 174 | + | let y = |v: f64| top + ph * (hi - v) / span; |
|
| 175 | + | let mut svg = format!(r#"<svg class="cov-chart" viewBox="0 0 {w} {h}" role="img" aria-label="{label}">"#); |
|
| 176 | + | for i in 0..=4 { |
|
| 177 | + | let v = lo + span * i as f64 / 4.0; |
|
| 178 | + | svg.push_str(&format!( |
|
| 179 | + | r#"<line class="grid" x1="{left}" x2="{x2}" y1="{yy:.1}" y2="{yy:.1}"/><text class="axis" x="{tx}" y="{ty:.1}" text-anchor="end">{t}</text>"#, |
|
| 180 | + | x2 = w - right, |
|
| 181 | + | yy = y(v), |
|
| 182 | + | tx = left - 6.0, |
|
| 183 | + | ty = y(v) + 3.5, |
|
| 184 | + | t = fmt(v), |
|
| 185 | + | )); |
|
| 74 | 186 | } |
|
| 75 | - | let (w, h, pad) = (640.0, 120.0, 8.0); |
|
| 76 | - | let lo = points.iter().map(|p| p.0).fold(100.0f64, f64::min).floor().max(0.0); |
|
| 77 | - | let hi = points.iter().map(|p| p.0).fold(0.0f64, f64::max).ceil().min(100.0); |
|
| 78 | - | let (lo, hi) = if hi - lo < 2.0 { ((lo - 1.0).max(0.0), (hi + 1.0).min(100.0)) } else { (lo, hi) }; |
|
| 79 | - | let step = (w - 2.0 * pad) / (points.len() - 1) as f64; |
|
| 80 | - | let y = |p: f64| pad + (hi - p) / (hi - lo) * (h - 2.0 * pad); |
|
| 81 | - | let line: Vec<String> = points.iter().enumerate().map(|(i, (p, _))| format!("{:.1},{:.1}", pad + i as f64 * step, y(*p))).collect(); |
|
| 82 | - | let mut svg = format!( |
|
| 83 | - | r#"<svg class="cov-trend" viewBox="0 0 {w} {h}" role="img" aria-label="Coverage over the last {n} runs, from {first:.1}% to {last:.1}%"><text x="{w}" y="12" text-anchor="end" class="axis">{hi}%</text><text x="{w}" y="{hb}" text-anchor="end" class="axis">{lo}%</text><polyline points="{pts}"/>"#, |
|
| 84 | - | n = points.len(), |
|
| 85 | - | first = points[0].0, |
|
| 86 | - | last = points[points.len() - 1].0, |
|
| 87 | - | hb = h - 2.0, |
|
| 88 | - | pts = line.join(" "), |
|
| 89 | - | ); |
|
| 90 | - | for (i, (p, r)) in points.iter().enumerate() { |
|
| 187 | + | for s in series { |
|
| 188 | + | let pts: Vec<String> = s.values.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); |
|
| 189 | + | if n > 1 { |
|
| 190 | + | let area = match &s.floor { |
|
| 191 | + | Some(floor) => { |
|
| 192 | + | let back: Vec<String> = floor.iter().enumerate().rev().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect(); |
|
| 193 | + | format!("M{} L{} Z", pts.join(" L"), back.join(" L")) |
|
| 194 | + | } |
|
| 195 | + | None => format!("M{:.1},{:.1} L{} L{:.1},{:.1} Z", x(0), top + ph, pts.join(" L"), x(n - 1), top + ph), |
|
| 196 | + | }; |
|
| 197 | + | svg.push_str(&format!( |
|
| 198 | + | r#"<path class="area {c}" d="{area}"/><polyline class="line {c}" points="{line}"/>"#, |
|
| 199 | + | c = s.class, |
|
| 200 | + | line = pts.join(" ") |
|
| 201 | + | )); |
|
| 202 | + | } |
|
| 203 | + | if let Some(last) = s.values.last() { |
|
| 204 | + | svg.push_str(&format!(r#"<circle class="dot {}" cx="{:.1}" cy="{:.1}" r="4"/>"#, s.class, x(n - 1), y(*last))); |
|
| 205 | + | } |
|
| 206 | + | } |
|
| 207 | + | // A column per run: its tip on hover, its report on a click. |
|
| 208 | + | let col = if n == 1 { pw } else { pw / (n - 1) as f64 }; |
|
| 209 | + | for (i, r) in runs.iter().enumerate() { |
|
| 91 | 210 | svg.push_str(&format!( |
|
| 92 | - | r#"<a href="{url}/coverage?report={id}"><circle cx="{x:.1}" cy="{y:.1}" r="3.5"><title>Run #{run}: {p:.1}%</title></circle></a>"#, |
|
| 211 | + | r#"<a href="{url}/coverage?report={id}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#, |
|
| 93 | 212 | id = r.id, |
|
| 94 | - | x = pad + i as f64 * step, |
|
| 95 | - | y = y(*p), |
|
| 96 | - | run = r.run_number, |
|
| 213 | + | rx = x(i) - col / 2.0, |
|
| 214 | + | t = tip(r), |
|
| 97 | 215 | )); |
|
| 98 | 216 | } |
|
| 217 | + | if n > 1 { |
|
| 218 | + | svg.push_str(&format!( |
|
| 219 | + | r#"<text class="axis" x="{left}" y="{yb}">run #{first}</text><text class="axis" x="{xr}" y="{yb}" text-anchor="end">run #{last}</text>"#, |
|
| 220 | + | yb = h - 6.0, |
|
| 221 | + | xr = w - right, |
|
| 222 | + | first = runs[0].run_number, |
|
| 223 | + | last = runs[n - 1].run_number, |
|
| 224 | + | )); |
|
| 225 | + | } |
|
| 99 | 226 | svg.push_str("</svg>"); |
|
| 100 | 227 | svg |
|
| 101 | 228 | } |
|
| … | 8 unchanged lines |
||
| 110 | 237 | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); |
|
| 111 | 238 | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 112 | 239 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 113 | - | let report = match q.report { |
|
| 240 | + | let picked = match q.report { |
|
| 114 | 241 | Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?), |
|
| 115 | 242 | None => None, |
|
| 116 | 243 | }; |
|
| 117 | - | let bookmark = report.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 118 | - | let history = coverage::on_bookmark(&app.db, row.id, &bookmark, TREND).await.map_err(e)?; |
|
| 119 | - | let report = report.or_else(|| history.first().cloned()); |
|
| 120 | - | let delta = report.as_ref().and_then(|r| { |
|
| 121 | - | let before = history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id))?; |
|
| 122 | - | Some(r.percent()? - before.percent()?) |
|
| 123 | - | }); |
|
| 244 | + | let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 245 | + | let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?; |
|
| 246 | + | let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?; |
|
| 247 | + | let report = picked.or_else(|| history.first().cloned()); |
|
| 248 | + | // The report before the one shown, on the same bookmark. |
|
| 249 | + | let previous = report |
|
| 250 | + | .as_ref() |
|
| 251 | + | .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned()); |
|
| 252 | + | let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?)); |
|
| 124 | 253 | let mut files = match &report { |
|
| 125 | 254 | Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?, |
|
| 126 | 255 | None => vec![], |
|
| 127 | 256 | }; |
|
| 257 | + | // Files that moved against the run before, the largest moves first. |
|
| 258 | + | let (mut moved, mut added) = (Vec::new(), 0); |
|
| 259 | + | if let Some(p) = &previous { |
|
| 260 | + | let before: std::collections::HashMap<String, Option<f64>> = |
|
| 261 | + | coverage::files(&app.db, p.id).await.map_err(e)?.into_iter().map(|f| (f.path.clone(), f.percent())).collect(); |
|
| 262 | + | for f in &files { |
|
| 263 | + | match before.get(&f.path) { |
|
| 264 | + | None => added += 1, |
|
| 265 | + | Some(b) => { |
|
| 266 | + | let d = f.percent().unwrap_or(0.0) - b.unwrap_or(0.0); |
|
| 267 | + | if d.abs() >= 0.05 { |
|
| 268 | + | moved.push(Moved { path: f.path.clone(), before: *b, after: f.percent(), delta: d }); |
|
| 269 | + | } |
|
| 270 | + | } |
|
| 271 | + | } |
|
| 272 | + | } |
|
| 273 | + | moved.sort_by(|a, b| b.delta.abs().total_cmp(&a.delta.abs()).then(a.path.cmp(&b.path))); |
|
| 274 | + | moved.truncate(CHANGED); |
|
| 275 | + | } |
|
| 276 | + | // How the files spread over coverage bands. |
|
| 277 | + | let bounds: [(&str, &str, f64, f64); 5] = [ |
|
| 278 | + | ("100%", "cov-good", 100.0, 100.1), |
|
| 279 | + | ("90 – 99%", "cov-good", 90.0, 100.0), |
|
| 280 | + | ("75 – 89%", "cov-fair", 75.0, 90.0), |
|
| 281 | + | ("50 – 74%", "cov-low", 50.0, 75.0), |
|
| 282 | + | ("under 50%", "cov-poor", -1.0, 50.0), |
|
| 283 | + | ]; |
|
| 284 | + | let counts: Vec<usize> = |
|
| 285 | + | bounds.iter().map(|(_, _, lo, hi)| files.iter().filter(|f| f.percent().is_some_and(|p| p >= *lo && p < *hi)).count()).collect(); |
|
| 286 | + | let widest = counts.iter().copied().max().unwrap_or(0).max(1) as f64; |
|
| 287 | + | let bands = bounds |
|
| 288 | + | .iter() |
|
| 289 | + | .zip(&counts) |
|
| 290 | + | .map(|((label, class, _, _), n)| Band { label, class, files: *n, width: *n as f64 * 100.0 / widest }) |
|
| 291 | + | .collect(); |
|
| 128 | 292 | let sort = match q.sort.as_deref() { |
|
| 129 | 293 | Some("percent") => "percent", |
|
| 130 | 294 | Some("path") => "path", |
|
| … | 5 unchanged lines |
||
| 136 | 300 | _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))), |
|
| 137 | 301 | } |
|
| 138 | 302 | let repo = RepoShell::new(&app, &row, "coverage").with_role(role); |
|
| 139 | - | let trend = trend_svg(&history, &repo.url); |
|
| 140 | - | Ok(Page(CoveragePage { shell, repo, bookmark, report, delta, files, sort: sort.into(), trend, runs: history.len() })) |
|
| 303 | + | // Charts: oldest on the left. |
|
| 304 | + | let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.lines_found > 0).collect(); |
|
| 305 | + | let pcts: Vec<f64> = oldest_first.iter().map(|r| r.percent().unwrap_or(0.0)).collect(); |
|
| 306 | + | let (lo, hi) = ( |
|
| 307 | + | (pcts.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0), |
|
| 308 | + | (pcts.iter().copied().fold(0.0, f64::max) + 2.0).ceil().min(100.0), |
|
| 309 | + | ); |
|
| 310 | + | let tip = |r: &Report| { |
|
| 311 | + | format!( |
|
| 312 | + | "Run #{} · {} · {} of {} lines", |
|
| 313 | + | r.run_number, |
|
| 314 | + | r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(), |
|
| 315 | + | r.lines_hit, |
|
| 316 | + | r.lines_found |
|
| 317 | + | ) |
|
| 318 | + | }; |
|
| 319 | + | let coverage_chart = chart( |
|
| 320 | + | &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()), |
|
| 321 | + | &oldest_first, |
|
| 322 | + | &[Series { values: pcts, class: "cov", floor: None }], |
|
| 323 | + | lo, |
|
| 324 | + | hi, |
|
| 325 | + | &|v| format!("{v:.0}%"), |
|
| 326 | + | &repo.url, |
|
| 327 | + | &tip, |
|
| 328 | + | ); |
|
| 329 | + | let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64; |
|
| 330 | + | let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect(); |
|
| 331 | + | let lines_chart = chart( |
|
| 332 | + | &format!("Relevant and covered lines of {bookmark}"), |
|
| 333 | + | &oldest_first, |
|
| 334 | + | &[ |
|
| 335 | + | Series { values: covered.clone(), class: "covered", floor: None }, |
|
| 336 | + | Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) }, |
|
| 337 | + | ], |
|
| 338 | + | 0.0, |
|
| 339 | + | (top * 1.08).max(1.0), |
|
| 340 | + | &compact, |
|
| 341 | + | &repo.url, |
|
| 342 | + | &tip, |
|
| 343 | + | ); |
|
| 344 | + | let recent = history |
|
| 345 | + | .iter() |
|
| 346 | + | .enumerate() |
|
| 347 | + | .take(RECENT) |
|
| 348 | + | .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).and_then(|p| Some(r.percent()? - p.percent()?)) }) |
|
| 349 | + | .collect(); |
|
| 350 | + | let first_ms = coverage::first_ms(&app.db, row.id).await.map_err(e)?; |
|
| 351 | + | Ok(Page(CoveragePage { |
|
| 352 | + | shell, |
|
| 353 | + | repo, |
|
| 354 | + | bookmark, |
|
| 355 | + | bookmarks, |
|
| 356 | + | report, |
|
| 357 | + | previous, |
|
| 358 | + | delta, |
|
| 359 | + | files, |
|
| 360 | + | sort: sort.into(), |
|
| 361 | + | coverage_chart, |
|
| 362 | + | lines_chart, |
|
| 363 | + | runs: oldest_first.len(), |
|
| 364 | + | first_ms, |
|
| 365 | + | recent, |
|
| 366 | + | moved, |
|
| 367 | + | added, |
|
| 368 | + | bands, |
|
| 369 | + | })) |
|
| 141 | 370 | } |
|
| 142 | 371 | ||
| 143 | 372 | /// One line of a file, as the file view shows it. |
|
| … | 123 unchanged lines |
||
| 267 | 496 | ) |
|
| 268 | 497 | .into_response()) |
|
| 269 | 498 | } |
|
| 270 | - |
modified crates/hub/tests/flow.rs +6 −1
| … | 2166 unchanged lines |
||
| 2167 | 2167 | ||
| 2168 | 2168 | let (st, page) = call(&h.app, "GET", "/andy/cov/coverage", None, None).await; |
|
| 2169 | 2169 | assert_eq!(st, StatusCode::OK); |
|
| 2170 | - | assert!(page.contains(">Coverage</a>") && page.contains("50.0%") && page.contains("2 of 4 run"), "{page}"); |
|
| 2170 | + | assert!( |
|
| 2171 | + | page.contains(">Coverage</a>") |
|
| 2172 | + | && page.contains("50.0%") |
|
| 2173 | + | && page.contains("<strong>2</strong> of <strong>4</strong> relevant lines covered"), |
|
| 2174 | + | "{page}" |
|
| 2175 | + | ); |
|
| 2171 | 2176 | assert!(page.contains("1 of 2") && page.contains("/andy/cov/coverage/") && page.contains("/file/src/lib.rs"), "{page}"); |
|
| 2172 | 2177 | let report = crate_report(&h.app, run.id).await; |
|
| 2173 | 2178 | let (st, file) = call(&h.app, "GET", &format!("/andy/cov/coverage/{report}/file/src/lib.rs"), None, None).await; |
|
| … | 14 unchanged lines |
modified docs/spec.md +1 −1
| … | 586 unchanged lines |
||
| 587 | 587 | - **Reports.** A job's artifact named `coverage` in LCOV format (`cargo llvm-cov --lcov`, Istanbul's `lcov` reporter, `coverage lcov`, gcov with lcov) is read when the job uploads it. The hub writes what it found into the job's log (`coverage: 88.6% of lines (951 of 1073) in 5 files`), or why it could not read it; a bad report never fails the upload. At most 64 MiB is read. |
|
| 588 | 588 | - **Counting.** Paths are taken relative to the job's `/src`; a path outside the repository, or a file the run's state does not hold, is left out and counted. Records for one file are added together. A file with a single record keeps the tool's own `LF`/`LH` totals, since `llvm-cov` declares lines its `DA` lines leave out; the totals then match what the tool printed. |
|
| 589 | 589 | - **Stored.** `coverage_reports` keeps each report's totals (lines, functions, branches) with its run, job, bookmark and state; `coverage_files` each file's counts. The per-line counts stay in the artifact's blob and are read again for the file view. |
|
| 590 | - | - **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before, a trend of the last 30 runs, every file with a bar (sorted by lines missed, lowest coverage, or path), and a file view with each line run or missed and how often it ran. `/OWNER/NAME/coverage/badge.svg` is a badge of the default bookmark's latest coverage. The run page shows each job's coverage beside its artifacts. |
|
| 590 | + | - **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before over a chart of up to 200 runs (each point names its run and opens its report), a strip of facts (lines covered, hits per line, functions, branches, files, the first report, the badge with its Markdown), this run's changes with the files whose coverage moved most, files by coverage band, a chart of covered and missed lines, the recent runs, a bookmark picker, every file with a bar (sorted by lines missed, lowest coverage, or path), and a file view with each line run or missed and how often it ran. `/OWNER/NAME/coverage/badge.svg` is a badge of the default bookmark's latest coverage. The run page shows each job's coverage beside its artifacts. |
|
| 591 | 591 | - **Next:** coverage of a proposal's projected state against its target, file by file, and a `coverage` check with a minimum and a largest drop set in the repository's settings; Cobertura XML; the API; and a coverage job in Oxygen's own pipeline (cargo-llvm-cov in the Flow build image). |
|
| 592 | 592 | ||
| 593 | 593 | ## 14. Git interoperability |
|
| … | 146 unchanged lines |
modified templates/coverage.html +64 −12
| … | 2 unchanged lines |
||
| 3 | 3 | {% block title %}Coverage · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block tabs %}{% call m::tabs(repo, "coverage") %}{% endblock %} |
|
| 5 | 5 | {% block content %} |
|
| 6 | - | <div class="head"><h1>Coverage <small>of {{ bookmark }}</small></h1></div> |
|
| 7 | 6 | {% if let Some(r) = report %} |
|
| 8 | - | <section class="cov-summary"> |
|
| 9 | - | <div class="cov-big {{ self.class_of(r.percent()) }}">{{ self.pct(r.percent()) }}{% if let Some(d) = self.delta_text() %} <span class="cov-delta" title="Against the run before it on {{ bookmark }}">{{ d }}</span>{% endif %}</div> |
|
| 10 | - | <dl class="facts"> |
|
| 11 | - | <dt>Lines</dt><dd>{{ r.lines_hit }} of {{ r.lines_found }} run</dd> |
|
| 12 | - | {% if r.functions_found > 0 %}<dt>Functions</dt><dd>{{ self.pct(r.functions_percent()) }} ({{ r.functions_hit }} of {{ r.functions_found }})</dd>{% endif %} |
|
| 13 | - | {% if r.branches_found > 0 %}<dt>Branches</dt><dd>{{ self.pct(r.branches_percent()) }} ({{ r.branches_hit }} of {{ r.branches_found }})</dd>{% endif %} |
|
| 14 | - | <dt>From</dt><dd><a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a>, job {{ r.job }}, <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></dd> |
|
| 15 | - | <dt>Files</dt><dd>{{ r.files }}{% if r.dropped > 0 %} <span class="mute small">({{ r.dropped }} named in the report are not in this state and are left out)</span>{% endif %}</dd> |
|
| 16 | - | <dt>Badge</dt><dd><img src="{{ repo.url }}/coverage/badge.svg" alt="coverage badge"> <code class="small">[]({{ shell.base_url }}{{ repo.url }}/coverage)</code></dd> |
|
| 17 | - | </dl> |
|
| 7 | + | <section class="cov-hero"> |
|
| 8 | + | <div class="cov-hero-head"> |
|
| 9 | + | <div> |
|
| 10 | + | <h1>Coverage</h1> |
|
| 11 | + | <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/coverage"><label for="cov-bm" class="mute">bookmark</label> <select id="cov-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %} |
|
| 12 | + | · <a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a> · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div> |
|
| 13 | + | </div> |
|
| 14 | + | <div class="cov-score {{ self.class_of(r.percent()) }}"> |
|
| 15 | + | <div class="cov-big">{{ self.pct(r.percent()) }}</div> |
|
| 16 | + | {% if let Some(d) = delta %}<div class="cov-change {{ self.delta_class(d) }}" title="Against run #{% if let Some(p) = previous %}{{ p.run_number }}{% endif %}">{{ self.signed(d) }} since the run before</div>{% else %}<div class="cov-change same">the first report here</div>{% endif %} |
|
| 17 | + | </div> |
|
| 18 | + | </div> |
|
| 19 | + | {% if runs > 1 %}<div class="cov-hero-chart">{{ coverage_chart|safe }}</div>{% endif %} |
|
| 20 | + | </section> |
|
| 21 | + | ||
| 22 | + | <section class="cov-facts"> |
|
| 23 | + | <div><span class="k">Lines covered</span><span class="v">{{ r.lines_hit }} <small>of {{ r.lines_found }}</small></span></div> |
|
| 24 | + | <div><span class="k">Hits per line</span><span class="v">{{ self.hits(r) }}</span></div> |
|
| 25 | + | {% if r.functions_found > 0 %}<div><span class="k">Functions</span><span class="v">{{ self.pct(r.functions_percent()) }} <small>{{ r.functions_hit }} of {{ r.functions_found }}</small></span></div>{% endif %} |
|
| 26 | + | {% if r.branches_found > 0 %}<div><span class="k">Branches</span><span class="v">{{ self.pct(r.branches_percent()) }} <small>{{ r.branches_hit }} of {{ r.branches_found }}</small></span></div>{% endif %} |
|
| 27 | + | <div><span class="k">Files</span><span class="v">{{ r.files }}</span></div> |
|
| 28 | + | {% if let Some(t) = first_ms %}<div><span class="k">Reporting since</span><span class="v small"><time title="{{ t|when_i }}">{{ t|ago_i }}</time></span></div>{% endif %} |
|
| 29 | + | <div><span class="k">Badge</span><span class="v"><img src="{{ repo.url }}/coverage/badge.svg" alt="coverage {{ self.pct(r.percent()) }}" class="cov-badge"><button type="button" class="cov-copy" data-copy="[]({{ shell.base_url }}{{ repo.url }}/coverage)" title="Copy the Markdown for a README">copy Markdown</button></span></div> |
|
| 18 | 30 | </section> |
|
| 19 | - | {% if !trend.is_empty() %}<section class="panel"><h2>The last {{ runs }} runs</h2>{{ trend|safe }}</section>{% endif %} |
|
| 31 | + | ||
| 32 | + | <div class="cov-grid"> |
|
| 33 | + | <section class="panel cov-card"> |
|
| 34 | + | <h2>This run</h2> |
|
| 35 | + | <ul class="cov-stats"> |
|
| 36 | + | <li><strong>{{ r.lines_hit }}</strong> of <strong>{{ r.lines_found }}</strong> relevant lines covered <span class="{{ self.class_of(r.percent()) }}">({{ self.pct(r.percent()) }})</span></li> |
|
| 37 | + | {% if let Some((found, hit)) = self.lines_delta() %}<li>{% if *found >= 0 %}+{% endif %}{{ found }} relevant lines, {% if *hit >= 0 %}+{% endif %}{{ hit }} covered, against run #{% if let Some(p) = previous %}{{ p.run_number }}{% endif %}</li>{% endif %} |
|
| 38 | + | <li><strong>{{ self.hits(r) }}</strong> hits per line</li> |
|
| 39 | + | <li>Job <strong>{{ r.job }}</strong>{% if r.dropped > 0 %}; {{ r.dropped }} files the report named are not in this state and are left out{% endif %}</li> |
|
| 40 | + | {% if added > 0 %}<li>{{ added }} new files</li>{% endif %} |
|
| 41 | + | </ul> |
|
| 42 | + | {% if !moved.is_empty() %}<h3>Coverage moved in</h3> |
|
| 43 | + | <table class="cov-moved">{% for f in moved %}<tr><td><a href="{{ repo.url }}/coverage/{{ r.id }}/file/{{ f.path }}">{{ f.path }}</a></td><td class="num mute">{{ self.pct_of(f.before) }} → {{ self.pct_of(f.after) }}</td><td class="num cov-change {{ self.delta_class(&f.delta) }}">{{ self.signed(&f.delta) }}</td></tr>{% endfor %}</table>{% endif %} |
|
| 44 | + | </section> |
|
| 45 | + | <section class="panel cov-card"> |
|
| 46 | + | <h2>Files by coverage</h2> |
|
| 47 | + | <div class="cov-bands">{% for b in bands %}<div class="cov-band"><span class="cov-band-lbl">{{ b.label }}</span><span class="bar {{ b.class }}"><span style="width: {{ b.width }}%"></span></span><span class="n">{{ b.files }}</span></div>{% endfor %}</div> |
|
| 48 | + | </section> |
|
| 49 | + | </div> |
|
| 50 | + | ||
| 51 | + | {% if runs > 1 %}<section class="panel"> |
|
| 52 | + | <h2>Relevant lines covered <small class="mute"><span class="cov-key covered">covered</span> <span class="cov-key relevant">missed</span></small></h2> |
|
| 53 | + | {{ lines_chart|safe }} |
|
| 54 | + | </section>{% endif %} |
|
| 55 | + | ||
| 56 | + | {% if recent.len() > 1 %}<section class="panel"> |
|
| 57 | + | <h2>Recent runs on {{ bookmark }}</h2> |
|
| 58 | + | <div class="table"><table class="cov-runs"> |
|
| 59 | + | <tr><th>Run</th><th>Job</th><th>Coverage</th><th class="num">Change</th><th class="num">Lines</th><th>When</th></tr> |
|
| 60 | + | {% for x in recent %}<tr{% if x.report.id == r.id %} class="on"{% endif %}> |
|
| 61 | + | <td><a href="{{ repo.url }}/coverage?report={{ x.report.id }}">#{{ x.report.run_number }}</a></td> |
|
| 62 | + | <td>{{ x.report.job }}</td> |
|
| 63 | + | <td><span class="cov-bar {{ self.class_of(x.report.percent()) }}"><span style="width: {% if let Some(p) = x.report.percent() %}{{ p }}{% else %}0{% endif %}%"></span></span> {{ self.pct(x.report.percent()) }}</td> |
|
| 64 | + | <td class="num">{% if let Some(d) = x.delta %}<span class="cov-change {{ self.delta_class(d) }}">{{ self.signed(d) }}</span>{% endif %}</td> |
|
| 65 | + | <td class="num">{{ x.report.lines_hit }} / {{ x.report.lines_found }}</td> |
|
| 66 | + | <td class="small"><time title="{{ x.report.created_ms|when_i }}">{{ x.report.created_ms|ago_i }}</time></td> |
|
| 67 | + | </tr>{% endfor %} |
|
| 68 | + | </table></div> |
|
| 69 | + | </section>{% endif %} |
|
| 70 | + | ||
| 20 | 71 | <section class="panel"> |
|
| 21 | 72 | <h2>Files <small class="mute">sorted by {% if sort == "missed" %}lines missed{% else if sort == "percent" %}coverage, lowest first{% else %}path{% endif %}: |
|
| 22 | 73 | {% if sort != "missed" %}<a href="?report={{ r.id }}&sort=missed">lines missed</a> {% endif %}{% if sort != "percent" %}<a href="?report={{ r.id }}&sort=percent">lowest first</a> {% endif %}{% if sort != "path" %}<a href="?report={{ r.id }}&sort=path">path</a>{% endif %}</small></h2> |
|
| … | 8 unchanged lines |
||
| 31 | 82 | </table></div> |
|
| 32 | 83 | </section> |
|
| 33 | 84 | {% else %} |
|
| 85 | + | <div class="head"><h1>Coverage <small>of {{ bookmark }}</small></h1></div> |
|
| 34 | 86 | <section class="panel"> |
|
| 35 | 87 | <p>No coverage report for <strong>{{ bookmark }}</strong> yet.</p> |
|
| 36 | 88 | <p>Give a job in <code>.flow/pipeline.kdl</code> an artifact named <code>coverage</code> holding an LCOV report, and the hub reads it when the job uploads it. For Rust:</p> |
|
| … | 11 unchanged lines |
modified templates/style.css +46 −15
| … | 809 unchanged lines |
||
| 810 | 810 | figure.mermaid-diagram { margin: 1em 0; overflow-x: auto; text-align: center; } |
|
| 811 | 811 | figure.mermaid-diagram svg { max-width: 100%; height: auto; } |
|
| 812 | 812 | ||
| 813 | - | /* Coverage (#113). */ |
|
| 814 | - | .cov-summary { display: flex; gap: 2em; align-items: center; flex-wrap: wrap; margin: 1em 0; } |
|
| 815 | - | .cov-big { font-size: 3em; font-weight: 600; line-height: 1; } |
|
| 816 | - | .cov-delta { font-size: .35em; vertical-align: middle; opacity: .8; } |
|
| 817 | - | .cov-good { color: #1a7f37; } .cov-fair { color: #4d7c0f; } .cov-low { color: #9a6700; } .cov-poor { color: #cf222e; } .cov-none { color: inherit; } |
|
| 818 | - | .cov-bar { display: inline-block; width: 8em; height: .7em; background: rgba(127,127,127,.2); border-radius: 3px; vertical-align: middle; overflow: hidden; } |
|
| 813 | + | /* Coverage (#113): the theme's own colours, so it fits every look, light or dark. */ |
|
| 814 | + | .cov-good { color: var(--ok); } .cov-fair { color: color-mix(in srgb, var(--ok) 60%, var(--warn)); } .cov-low { color: var(--warn); } .cov-poor { color: var(--bad); } .cov-none { color: var(--mute); } |
|
| 815 | + | .cov-hero { border: 1px solid var(--line); border-radius: 10px; background: linear-gradient(180deg, var(--soft), var(--panel)); padding: 1.1em 1.3em .4em; margin: 1em 0; overflow: hidden; } |
|
| 816 | + | .cov-hero-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1em; flex-wrap: wrap; } |
|
| 817 | + | .cov-hero h1 { margin: 0; font-size: 1.6em; letter-spacing: .01em; } |
|
| 818 | + | .cov-sub { margin-top: .3em; font-size: .95em; } |
|
| 819 | + | .cov-score { text-align: right; } |
|
| 820 | + | .cov-big { font-size: 3.4em; font-weight: 700; line-height: 1; letter-spacing: -.02em; } |
|
| 821 | + | .cov-change { font-size: .9em; font-weight: 600; } |
|
| 822 | + | .cov-change.up { color: var(--ok); } .cov-change.down { color: var(--bad); } .cov-change.same { color: var(--mute); font-weight: 400; } |
|
| 823 | + | .cov-hero-chart { margin: .4em -.6em 0; } |
|
| 824 | + | .cov-chart { width: 100%; height: auto; display: block; } |
|
| 825 | + | .cov-chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 4; } |
|
| 826 | + | .cov-chart .axis { font-size: 11px; fill: var(--mute); } |
|
| 827 | + | .cov-chart .area { stroke: none; } |
|
| 828 | + | .cov-chart .line { fill: none; stroke-width: 2.2; stroke-linejoin: round; } |
|
| 829 | + | .cov-chart .area.cov { fill: color-mix(in srgb, var(--ok) 18%, transparent); } .cov-chart .line.cov, .cov-chart .dot.cov { stroke: var(--ok); fill: var(--ok); } |
|
| 830 | + | .cov-chart .line.cov { fill: none; } |
|
| 831 | + | .cov-chart .area.relevant { fill: color-mix(in srgb, var(--bad) 14%, transparent); } .cov-chart .line.relevant { stroke: var(--bad); } .cov-chart .dot.relevant { fill: var(--bad); } |
|
| 832 | + | .cov-chart .area.covered { fill: color-mix(in srgb, var(--ok) 30%, transparent); } .cov-chart .line.covered { stroke: var(--ok); } .cov-chart .dot.covered { fill: var(--ok); } |
|
| 833 | + | .cov-chart .hit { fill: transparent; } .cov-chart .hit:hover { fill: color-mix(in srgb, var(--acc) 10%, transparent); } |
|
| 834 | + | .cov-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5em, 1fr)); gap: .6em; margin: 0 0 1em; } |
|
| 835 | + | .cov-facts > div { border: 1px solid var(--line); border-radius: 8px; padding: .55em .8em; background: var(--panel); } |
|
| 836 | + | .cov-facts .k { display: block; font-size: .72em; text-transform: uppercase; letter-spacing: .06em; color: var(--mute); } |
|
| 837 | + | .cov-facts .v { display: block; font-size: 1.25em; font-weight: 600; margin-top: .15em; } |
|
| 838 | + | .cov-facts .v small { font-size: .7em; font-weight: 400; color: var(--mute); } |
|
| 839 | + | .cov-badge { vertical-align: middle; } |
|
| 840 | + | .cov-copy { font-size: .65em; margin-left: .4em; vertical-align: middle; } |
|
| 841 | + | .cov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20em, 1fr)); gap: 1em; align-items: start; } |
|
| 842 | + | .cov-card h3 { font-size: .95em; margin: 1em 0 .4em; } |
|
| 843 | + | .cov-stats { list-style: none; padding: 0; margin: 0; line-height: 1.8; } |
|
| 844 | + | .cov-moved, .cov-runs { width: 100%; } |
|
| 845 | + | .cov-moved td { padding: .15em .3em; } .cov-moved td.num, .cov-files td.num, .cov-files th.num, .cov-runs .num { text-align: right; white-space: nowrap; } |
|
| 846 | + | .cov-runs tr.on td { background: var(--soft); } |
|
| 847 | + | .cov-bands { display: grid; gap: .55em; margin-top: .4em; } |
|
| 848 | + | .cov-band { display: grid; grid-template-columns: 6.5em 1fr 2.5em; align-items: center; gap: .6em; } |
|
| 849 | + | .cov-band-lbl { font-size: .9em; color: var(--mute); white-space: nowrap; } |
|
| 850 | + | .cov-band .bar { height: .9em; background: var(--soft); border-radius: 4px; overflow: hidden; } |
|
| 851 | + | .cov-band .bar > span { display: block; height: 100%; background: currentColor; border-radius: 4px; min-width: 2px; } |
|
| 852 | + | .cov-band .n { text-align: right; font-weight: 600; } |
|
| 853 | + | .cov-bar { display: inline-block; width: 8em; height: .7em; background: var(--soft); border-radius: 3px; vertical-align: middle; overflow: hidden; } |
|
| 819 | 854 | .cov-bar > span { display: block; height: 100%; background: currentColor; } |
|
| 820 | - | .cov-files td.num, .cov-files th.num { text-align: right; } |
|
| 821 | - | .cov-trend { width: 100%; max-width: 640px; height: auto; } |
|
| 822 | - | .cov-trend polyline { fill: none; stroke: #2da44e; stroke-width: 2; } |
|
| 823 | - | .cov-trend circle { fill: #2da44e; } |
|
| 824 | - | .cov-trend .axis { font-size: 10px; fill: currentColor; opacity: .6; } |
|
| 825 | - | .cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: rgba(45,164,78,.15); } |
|
| 826 | - | .cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: rgba(207,34,46,.18); } |
|
| 827 | - | .cov-code td.cov-hits { text-align: right; padding: 0 .5em; opacity: .6; font-size: .85em; white-space: nowrap; user-select: none; } |
|
| 828 | - | .cov-key { padding: 0 .4em; border-radius: 3px; } |
|
| 855 | + | .cov-key { padding: 0 .4em; border-radius: 3px; font-size: .85em; } |
|
| 856 | + | .cov-key.relevant { background: color-mix(in srgb, var(--bad) 18%, transparent); } .cov-key.covered { background: color-mix(in srgb, var(--ok) 22%, transparent); } |
|
| 857 | + | .cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: color-mix(in srgb, var(--ok) 16%, transparent); } |
|
| 858 | + | .cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: color-mix(in srgb, var(--bad) 16%, transparent); } |
|
| 859 | + | .cov-code td.cov-hits { text-align: right; padding: 0 .5em; color: var(--mute); font-size: .85em; white-space: nowrap; user-select: none; } |