From 28a23c206f08304dc146ba323713bbba9fa1808a Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Fri, 2 Oct 2026 00:01:19 +0000 Subject: [PATCH 1/4] Plot per-engine subtest totals on WPT comparison charts --- scripts/test-wpt-history-tooltip.cjs | 110 +++++++++++ src/routes/wpt_compare.rs | 5 +- src/routes/wpt_history.rs | 279 ++++++++++++++++++++++++--- static/wpt-history-tooltip.js | 49 +++-- 4 files changed, 399 insertions(+), 44 deletions(-) create mode 100644 scripts/test-wpt-history-tooltip.cjs diff --git a/scripts/test-wpt-history-tooltip.cjs b/scripts/test-wpt-history-tooltip.cjs new file mode 100644 index 0000000..8e5b960 --- /dev/null +++ b/scripts/test-wpt-history-tooltip.cjs @@ -0,0 +1,110 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const source = fs.readFileSync(path.join(__dirname, "../static/wpt-history-tooltip.js"), "utf8"); + +function run(x, pass, total) { + return { x, v: [pass, total], rev: "rev-" + x, d: "2026-01-01", msg: null }; +} + +function setup(series, xMin = 0, xMax = 1) { + const children = []; + const handlers = {}; + function element() { + return { + style: {}, + attributes: {}, + offsetWidth: 260, + setAttribute(name, value) { this.attributes[name] = value; }, + }; + } + const rect = { left: 0, top: 0, width: 900 }; + const svg = { + appendChild(child) { children.push(child); }, + addEventListener(name, handler) { handlers[name] = handler; }, + getBoundingClientRect() { return rect; }, + }; + const container = { + clientWidth: 900, + appendChild(child) { children.push(child); }, + querySelector() { return svg; }, + getBoundingClientRect() { return rect; }, + }; + const data = { + dataset: {}, + parentElement: container, + textContent: JSON.stringify({ + width: 900, plot: [50, 25, 765, 235], xMin, xMax, series, + }), + }; + vm.runInNewContext(source, { + document: { + querySelectorAll() { return [data]; }, + createElement: element, + createElementNS: element, + }, + }); + return { + tip: children[0], + dot: children[2], + hover(x, y) { handlers.mousemove({ clientX: x, clientY: y }); }, + leave() { handlers.mouseleave(); }, + }; +} + +function series(metric, total, runs) { + return { name: "Blitz", color: "#000000", metric, total, first: 0, runs }; +} + +test("total hover uses the count axis and reports count changes", () => { + const runs = [run(0, 50, 100), run(1, 100, 200)]; + const chart = setup([series("percent", 1000, runs), series("total", 250, runs)]); + chart.hover(815, 72); + assert.equal(chart.tip.style.display, "block"); + assert.match(chart.tip.innerHTML, /200 total subtests/); + assert.match(chart.tip.innerHTML, /Change: \+100 subtests/); + assert.equal(chart.dot.attributes.cy, 72); + chart.leave(); + assert.equal(chart.tip.style.display, "none"); +}); + +test("percentage hover retains the fixed denominator and score deltas", () => { + const runs = [run(0, 50, 100), run(1, 100, 200)]; + const chart = setup([series("percent", 1000, runs), series("total", 250, runs)]); + chart.hover(815, 236.5); + assert.match(chart.tip.innerHTML, /10\.0% \(100\/1,000\)/); + assert.match(chart.tip.innerHTML, /Change: \+50 \(\+5\.00%\)/); + assert.equal(chart.dot.attributes.cy, 236.5); +}); + +test("total hover reports decreases without treating them as score regressions", () => { + const chart = setup([series("total", 250, [run(0, 100, 200), run(1, 50, 100)])]); + chart.hover(815, 166); + assert.match(chart.tip.innerHTML, /100 total subtests/); + assert.match(chart.tip.innerHTML, /Change: -100 subtests/); + assert.doesNotMatch(chart.tip.innerHTML, /#c62828/); +}); + +test("single-run and missing-data charts avoid invalid tooltip coordinates", () => { + const chart = setup([series("total", 250, [run(1, 100, 200)])], 1, 1); + chart.hover(50, 72); + assert.match(chart.tip.innerHTML, /200 total subtests/); + assert.equal(chart.dot.attributes.cx, 50); + assert.equal(chart.dot.attributes.cy, 72); + assert.doesNotMatch(chart.tip.innerHTML, /Change:/); + + const missing = setup([series("total", 250, [{ ...run(0, 0, 0), v: null }])]); + missing.hover(50, 72); + assert.equal(missing.tip.style.display, "none"); +}); + +test("zero subtest totals are valid counts, not missing runs", () => { + const chart = setup([series("total", 250, [run(0, 50, 100), run(1, 0, 0)])]); + chart.hover(815, 260); + assert.match(chart.tip.innerHTML, /0 total subtests/); + assert.match(chart.tip.innerHTML, /Change: -100 subtests/); + assert.equal(chart.dot.attributes.cy, 260); +}); diff --git a/src/routes/wpt_compare.rs b/src/routes/wpt_compare.rs index fdf12c4..2c933c6 100644 --- a/src/routes/wpt_compare.rs +++ b/src/routes/wpt_compare.rs @@ -146,13 +146,14 @@ fn CompareHistoryChart( "Percentage of subtests passing over time, one master run per day. Every engine's percentage is relative to the same denominator: the total number of subtests known to any engine in the latest runs (so lines are not distorted by tests being added - to WPT, and an engine that skips tests is not flattered for it)." + to WPT, and an engine that skips tests is not flattered for it). Dashed lines show + each engine's total number of subtests in each run, using the right-hand axis." } ChartRangeSelector { current_range: range, base_path: if area.is_empty() { "/wpt".to_string() } else { format!("/wpt/{area}") }, } - HistoryLineChart { lines: history, range, height: 320.0 } + HistoryLineChart { lines: history, range, height: 320.0, show_subtest_totals: true } } } } diff --git a/src/routes/wpt_history.rs b/src/routes/wpt_history.rs index 2d8ffb4..f5e45a4 100644 --- a/src/routes/wpt_history.rs +++ b/src/routes/wpt_history.rs @@ -190,11 +190,17 @@ pub struct ChartLine { } struct Series { - label: String, color: &'static str, + metric: ChartMetric, points: Vec<(f64, f64)>, } +#[derive(Clone, Copy, PartialEq)] +enum ChartMetric { + PassPercent, + SubtestTotal, +} + /// The run identifier shown in a tooltip: browser versions as-is, long /// nightly versions and commit shas shortened fn short_revision(revision: &str) -> &str { @@ -213,16 +219,29 @@ fn subtest_pass_percent(run: &HistoryRun, area_idx: usize, subtest_total: u32) - Some(total_subtests_passed as f64 / subtest_total as f64 * 100.0) } -fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option { +fn area_series( + history: &WptHistory, + min_x: f64, + spec: &ChartSeries, + metric: ChartMetric, +) -> Option { let area_idx = history.focus_areas.iter().position(|a| *a == spec.area)?; - let subtest_total = history.subtest_total(area_idx)?; + let subtest_total = history.subtest_total(area_idx); let points: Vec<(f64, f64)> = history .runs .iter() .filter_map(|run| { Some(( parse_date(&run.date)?, - subtest_pass_percent(run, area_idx, subtest_total)?, + match metric { + ChartMetric::PassPercent => { + subtest_pass_percent(run, area_idx, subtest_total?)? + } + ChartMetric::SubtestTotal => { + let (_, _, total, _) = (*run.scores.get(area_idx)?)?; + total as f64 + } + }, )) }) .filter(|(x, _)| *x >= min_x) @@ -231,16 +250,34 @@ fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option f64 { + let max = series + .iter() + .filter(|s| s.metric == ChartMetric::SubtestTotal) + .flat_map(|s| &s.points) + .map(|(_, y)| *y) + .fold(1.0, f64::max); + let raw_step = max / 5.0; + let magnitude = 10.0_f64.powf(raw_step.log10().floor()).max(1.0); + let step = [1.0, 2.0, 5.0, 10.0] + .into_iter() + .find(|step| step * magnitude >= raw_step) + .unwrap_or(10.0) + * magnitude; + step * 5.0 +} + fn polyline_points( points: &[(f64, f64)], x_min: f64, x_max: f64, + y_max: f64, plot: (f64, f64, f64, f64), // (x, y, width, height) of plot area ) -> String { if points.is_empty() { @@ -255,7 +292,7 @@ fn polyline_points( let mut out = String::new(); let mut plot_point = |&(x, y): &(f64, f64)| { let sx = px + (x - x_min) / x_range * pw; - let sy = py + (1.0 - y / 100.0) * ph; + let sy = py + (1.0 - y / y_max) * ph; write!(out, "{sx:.1},{sy:.1} ").unwrap(); }; for point in points.iter().step_by(stride) { @@ -392,7 +429,12 @@ pub fn WptHistoryChart( /// Per-run tooltip data for one line: the runs from just before the visible /// range onwards, with the line's `[passed, total]` subtest counts -fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value { +fn tooltip_runs( + line: &ChartLine, + min_x: f64, + metric: ChartMetric, + subtest_max: f64, +) -> serde_json::Value { let history = &line.history; let area_idx = history .focus_areas @@ -415,7 +457,9 @@ fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value { let x = parse_date(&run.date)?; let value = area_idx .and_then(|idx| *run.scores.get(idx)?) - .filter(|(_, _, total_subtests, _)| *total_subtests != 0) + .filter(|(_, _, total_subtests, _)| { + metric == ChartMetric::SubtestTotal || *total_subtests != 0 + }) .map(|(_, _, total_subtests, total_subtests_passed)| { serde_json::json!([total_subtests_passed, total_subtests]) }) @@ -432,7 +476,12 @@ fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value { serde_json::json!({ "name": line.series.label, "color": line.series.color, - "total": subtest_total, + "metric": if metric == ChartMetric::SubtestTotal { "total" } else { "percent" }, + "total": if metric == ChartMetric::SubtestTotal { + Some(subtest_max) + } else { + subtest_total.map(f64::from) + }, "first": first_visible - start, "runs": runs, }) @@ -444,16 +493,31 @@ pub fn HistoryLineChart( lines: Vec, range: ChartRange, #[props(default = 440.0)] height: f64, + #[props(default = false)] show_subtest_totals: bool, ) -> Element { const WIDTH: f64 = 900.0; - let plot: (f64, f64, f64, f64) = (50.0, 15.0, WIDTH - 65.0, height - 55.0); + let plot: (f64, f64, f64, f64) = if show_subtest_totals { + (50.0, 25.0, WIDTH - 135.0, height - 85.0) + } else { + (50.0, 15.0, WIDTH - 65.0, height - 55.0) + }; let (px, py, pw, ph) = plot; let min_x = range.min_x_of(lines.iter().map(|line| &*line.history)); - let series: Vec = lines + let metrics = if show_subtest_totals { + &[ChartMetric::PassPercent, ChartMetric::SubtestTotal][..] + } else { + &[ChartMetric::PassPercent][..] + }; + let series: Vec = metrics .iter() - .filter_map(|line| area_series(&line.history, min_x, &line.series)) + .flat_map(|&metric| { + lines.iter().filter_map(move |line| { + area_series(&line.history, min_x, &line.series, metric) + }) + }) .collect(); + let subtest_max = subtest_axis_max(&series); let x_min = series .iter() @@ -470,7 +534,9 @@ pub fn HistoryLineChart( let ticks = month_ticks(x_min, x_max); let x_range = (x_max - x_min).max(f64::EPSILON); - let legend_step = (pw / series.len().max(1) as f64).min(140.0); + let legend_step = pw / lines.len().max(1) as f64; + let legend_step = if show_subtest_totals { legend_step } else { legend_step.min(140.0) }; + let legend_y = height - if show_subtest_totals { 30.0 } else { 10.0 }; // Per-run data for the hover tooltip (a JS progressive enhancement) let tooltip_data = serde_json::json!({ @@ -478,9 +544,11 @@ pub fn HistoryLineChart( "plot": [px, py, pw, ph], "xMin": x_min, "xMax": x_max, - "series": lines + "series": metrics .iter() - .map(|line| tooltip_runs(line, min_x)) + .flat_map(|&metric| lines.iter().map(move |line| { + tooltip_runs(line, min_x, metric, subtest_max) + })) .collect::>(), }) .to_string() @@ -519,6 +587,25 @@ pub fn HistoryLineChart( } } + if show_subtest_totals { + text { + x: "{px + pw + 6.0}", + y: "{py - 10.0}", + font_size: "12", + fill: "#666", + "Subtests" + } + for i in 0..=5 { + text { + x: "{px + pw + 6.0}", + y: "{py + ph * (1.0 - i as f64 / 5.0) + 4.0}", + font_size: "12", + fill: "#666", + {format!("{:.0}", subtest_max * i as f64 / 5.0)} + } + } + } + // X-axis ticks and labels (month boundaries) for (days, label) in ticks { line { @@ -542,29 +629,58 @@ pub fn HistoryLineChart( // Data series for (i, s) in series.iter().enumerate() { polyline { - points: polyline_points(&s.points, x_min, x_max, plot), + points: polyline_points( + &s.points, x_min, x_max, + if s.metric == ChartMetric::SubtestTotal { subtest_max } else { 100.0 }, + plot, + ), fill: "none", stroke: s.color, stroke_width: if i == 0 { "2.5" } else { "1.5" }, + stroke_dasharray: if s.metric == ChartMetric::SubtestTotal { "5,4" } else { "none" }, } } // Legend, spread across the plot width when there are many series - for (i, s) in series.iter().enumerate() { + for (i, line_spec) in lines.iter().enumerate() { line { x1: "{px + 10.0 + (i as f64) * legend_step}", x2: "{px + 34.0 + (i as f64) * legend_step}", - y1: "{height - 10.0}", - y2: "{height - 10.0}", - stroke: s.color, + y1: "{legend_y}", + y2: "{legend_y}", + stroke: line_spec.series.color, stroke_width: "3", } text { x: "{px + 40.0 + (i as f64) * legend_step}", - y: "{height - 6.0}", + y: "{legend_y + 4.0}", font_size: "12", fill: "#333", - {s.label.clone()} + {line_spec.series.label.clone()} + } + } + if show_subtest_totals { + for (i, metric) in metrics.iter().enumerate() { + line { + x1: "{px + 10.0 + i as f64 * 220.0}", + x2: "{px + 34.0 + i as f64 * 220.0}", + y1: "{height - 10.0}", + y2: "{height - 10.0}", + stroke: "#666", + stroke_width: "2", + stroke_dasharray: if *metric == ChartMetric::SubtestTotal { "5,4" } else { "none" }, + } + text { + x: "{px + 40.0 + i as f64 * 220.0}", + y: "{height - 6.0}", + font_size: "12", + fill: "#333", + if *metric == ChartMetric::SubtestTotal { + "Total subtests (right axis)" + } else { + "Score (left axis)" + } + } } } } @@ -638,7 +754,7 @@ pub fn WptHistorySparklines(history: ArcWptHistory, range: ChartRange) -> Elemen width: "100%", style: "border: 1px solid #ddd", polyline { - points: polyline_points(&points, x_min, x_max, PLOT), + points: polyline_points(&points, x_min, x_max, 100.0, PLOT), fill: "none", stroke: "#7986cb", stroke_width: "1.5", @@ -651,3 +767,120 @@ pub fn WptHistorySparklines(history: ArcWptHistory, range: ChartRange) -> Elemen } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn chart_line(totals: &[Option], union_total: u32) -> ChartLine { + ChartLine { + history: ArcWptHistory(Arc::new(WptHistory { + focus_areas: vec!["css".into()], + union_totals: vec![Some(union_total)], + runs: totals + .iter() + .enumerate() + .map(|(i, total)| HistoryRun { + date: format!("2026-01-{:02}", i + 1), + product_revision: format!("rev-{i}"), + commit_message: None, + scores: vec![total.map(|total| (10, 5.0, total, total / 2))], + }) + .collect(), + })), + series: ChartSeries { + area: "css".into(), + label: "Blitz".into(), + color: "#000000", + }, + } + } + + #[test] + fn totals_use_each_runs_count_not_the_union_denominator() { + let line = chart_line(&[Some(100), None, Some(0), Some(200)], 1000); + let totals = area_series( + &line.history, + f64::NEG_INFINITY, + &line.series, + ChartMetric::SubtestTotal, + ) + .unwrap(); + assert_eq!(totals.points.iter().map(|p| p.1).collect::>(), [100.0, 0.0, 200.0]); + let scores = area_series( + &line.history, + f64::NEG_INFINITY, + &line.series, + ChartMetric::PassPercent, + ) + .unwrap(); + assert_eq!(scores.points.iter().map(|p| p.1).collect::>(), [5.0, 10.0]); + + let min_x = parse_date("2026-01-04").unwrap(); + let visible = area_series(&line.history, min_x, &line.series, ChartMetric::SubtestTotal) + .unwrap(); + assert_eq!(visible.points, [(min_x, 200.0)]); + let data = tooltip_runs(&line, min_x, ChartMetric::SubtestTotal, 500.0); + assert_eq!(data["metric"], "total"); + assert_eq!(data["total"], 500.0); + assert_eq!(data["first"], 1); + assert_eq!(data["runs"][0]["v"], serde_json::json!([0, 0])); + assert_eq!(data["runs"][1]["v"], serde_json::json!([100, 200])); + } + + #[test] + fn total_axis_covers_visible_counts_with_integer_ticks() { + for (max, expected) in [(1.0, 5.0), (200.0, 250.0), (1200.0, 2500.0), (1_200_000.0, 2_500_000.0)] { + let series = Series { + color: "#000000", + metric: ChartMetric::SubtestTotal, + points: vec![(0.0, max)], + }; + assert_eq!(subtest_axis_max(&[series]), expected); + } + assert_eq!( + polyline_points(&[(0.0, 100.0), (1.0, 200.0)], 0.0, 1.0, 250.0, (50.0, 25.0, 100.0, 100.0)), + "50.0,85.0 150.0,45.0 " + ); + } + + #[test] + fn comparison_chart_renders_both_metrics_for_each_engine() { + let blitz = chart_line(&[Some(100), Some(200)], 1000); + let mut chrome = chart_line(&[Some(600), Some(800)], 1000); + chrome.series.label = "Chrome".into(); + chrome.series.color = "#e57373"; + let html = dioxus_ssr::render_element(rsx! { + HistoryLineChart { + lines: vec![blitz, chrome], + range: ChartRange::All, + height: 320.0, + show_subtest_totals: true, + } + }); + assert_eq!(html.matches("').unwrap(); + let data: serde_json::Value = serde_json::from_str(json.split("").next().unwrap()).unwrap(); + assert_eq!(data["series"].as_array().unwrap().len(), 4); + assert_eq!(data["series"][0]["metric"], "percent"); + assert_eq!(data["series"][2]["metric"], "total"); + assert_eq!(data["series"][2]["total"], data["series"][3]["total"]); + assert_eq!(data["series"][2]["runs"][1]["v"], serde_json::json!([100, 200])); + assert_eq!(data["series"][3]["runs"][1]["v"], serde_json::json!([400, 800])); + } + + #[test] + fn other_history_charts_remain_percentage_only() { + let line = chart_line(&[Some(100), Some(200)], 1000); + let html = dioxus_ssr::render_element(rsx! { + HistoryLineChart { lines: vec![line], range: ChartRange::All } + }); + assert_eq!(html.matches(" 0 ? s.runs[i - 1].v : null; - return p == null || cur[0] !== p[0] || cur[1] !== p[1]; + return p == null || (s.metric === "total" ? cur[1] !== p[1] : cur[0] !== p[0] || cur[1] !== p[1]); } var snapped = -1, snappedDist = SNAP_PX; for (var j = s.first; j < s.runs.length; j++) { @@ -148,19 +149,29 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat html += "
" + esc(run.msg) + "
"; } - var pass = run.v[0], total = s.total; - html += "
\u25CF " + - esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" + - pass.toLocaleString() + "/" + total.toLocaleString() + ")
"; - - // Change relative to the previous run - if (prev && prev.v != null) { - var dPass = pass - prev.v[0]; - var dPct = 100 * (dPass / total); - var sign = dPass > 0 ? "+" : ""; - var color = dPass > 0 ? "#2e7d32" : (dPass < 0 ? "#c62828" : "#666"); - html += "
Change: " + sign + - dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)
"; + if (s.metric === "total") { + html += "
\u2504 " + + esc(s.name) + ": " + run.v[1].toLocaleString() + " total subtests
"; + if (prev && prev.v != null) { + var dTotal = run.v[1] - prev.v[1]; + html += "
Change: " + (dTotal > 0 ? "+" : "") + + dTotal.toLocaleString() + " subtests
"; + } + } else { + var pass = run.v[0], total = s.total; + html += "
\u25CF " + + esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" + + pass.toLocaleString() + "/" + total.toLocaleString() + ")
"; + + // Change relative to the previous run + if (prev && prev.v != null) { + var dPass = pass - prev.v[0]; + var dPct = 100 * (dPass / total); + var sign = dPass > 0 ? "+" : ""; + var color = dPass > 0 ? "#2e7d32" : (dPass < 0 ? "#c62828" : "#666"); + html += "
Change: " + sign + + dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)
"; + } } tip.innerHTML = html; tip.style.display = "block"; From a1e359ef85369e2e935317a8bdbe47cdc9a0df17 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Fri, 2 Oct 2026 00:07:58 +0000 Subject: [PATCH 2/4] Remove added chart regression tests --- scripts/test-wpt-history-tooltip.cjs | 110 ------------------------- src/routes/wpt_history.rs | 117 --------------------------- 2 files changed, 227 deletions(-) delete mode 100644 scripts/test-wpt-history-tooltip.cjs diff --git a/scripts/test-wpt-history-tooltip.cjs b/scripts/test-wpt-history-tooltip.cjs deleted file mode 100644 index 8e5b960..0000000 --- a/scripts/test-wpt-history-tooltip.cjs +++ /dev/null @@ -1,110 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); -const vm = require("node:vm"); - -const source = fs.readFileSync(path.join(__dirname, "../static/wpt-history-tooltip.js"), "utf8"); - -function run(x, pass, total) { - return { x, v: [pass, total], rev: "rev-" + x, d: "2026-01-01", msg: null }; -} - -function setup(series, xMin = 0, xMax = 1) { - const children = []; - const handlers = {}; - function element() { - return { - style: {}, - attributes: {}, - offsetWidth: 260, - setAttribute(name, value) { this.attributes[name] = value; }, - }; - } - const rect = { left: 0, top: 0, width: 900 }; - const svg = { - appendChild(child) { children.push(child); }, - addEventListener(name, handler) { handlers[name] = handler; }, - getBoundingClientRect() { return rect; }, - }; - const container = { - clientWidth: 900, - appendChild(child) { children.push(child); }, - querySelector() { return svg; }, - getBoundingClientRect() { return rect; }, - }; - const data = { - dataset: {}, - parentElement: container, - textContent: JSON.stringify({ - width: 900, plot: [50, 25, 765, 235], xMin, xMax, series, - }), - }; - vm.runInNewContext(source, { - document: { - querySelectorAll() { return [data]; }, - createElement: element, - createElementNS: element, - }, - }); - return { - tip: children[0], - dot: children[2], - hover(x, y) { handlers.mousemove({ clientX: x, clientY: y }); }, - leave() { handlers.mouseleave(); }, - }; -} - -function series(metric, total, runs) { - return { name: "Blitz", color: "#000000", metric, total, first: 0, runs }; -} - -test("total hover uses the count axis and reports count changes", () => { - const runs = [run(0, 50, 100), run(1, 100, 200)]; - const chart = setup([series("percent", 1000, runs), series("total", 250, runs)]); - chart.hover(815, 72); - assert.equal(chart.tip.style.display, "block"); - assert.match(chart.tip.innerHTML, /200 total subtests/); - assert.match(chart.tip.innerHTML, /Change: \+100 subtests/); - assert.equal(chart.dot.attributes.cy, 72); - chart.leave(); - assert.equal(chart.tip.style.display, "none"); -}); - -test("percentage hover retains the fixed denominator and score deltas", () => { - const runs = [run(0, 50, 100), run(1, 100, 200)]; - const chart = setup([series("percent", 1000, runs), series("total", 250, runs)]); - chart.hover(815, 236.5); - assert.match(chart.tip.innerHTML, /10\.0% \(100\/1,000\)/); - assert.match(chart.tip.innerHTML, /Change: \+50 \(\+5\.00%\)/); - assert.equal(chart.dot.attributes.cy, 236.5); -}); - -test("total hover reports decreases without treating them as score regressions", () => { - const chart = setup([series("total", 250, [run(0, 100, 200), run(1, 50, 100)])]); - chart.hover(815, 166); - assert.match(chart.tip.innerHTML, /100 total subtests/); - assert.match(chart.tip.innerHTML, /Change: -100 subtests/); - assert.doesNotMatch(chart.tip.innerHTML, /#c62828/); -}); - -test("single-run and missing-data charts avoid invalid tooltip coordinates", () => { - const chart = setup([series("total", 250, [run(1, 100, 200)])], 1, 1); - chart.hover(50, 72); - assert.match(chart.tip.innerHTML, /200 total subtests/); - assert.equal(chart.dot.attributes.cx, 50); - assert.equal(chart.dot.attributes.cy, 72); - assert.doesNotMatch(chart.tip.innerHTML, /Change:/); - - const missing = setup([series("total", 250, [{ ...run(0, 0, 0), v: null }])]); - missing.hover(50, 72); - assert.equal(missing.tip.style.display, "none"); -}); - -test("zero subtest totals are valid counts, not missing runs", () => { - const chart = setup([series("total", 250, [run(0, 50, 100), run(1, 0, 0)])]); - chart.hover(815, 260); - assert.match(chart.tip.innerHTML, /0 total subtests/); - assert.match(chart.tip.innerHTML, /Change: -100 subtests/); - assert.equal(chart.dot.attributes.cy, 260); -}); diff --git a/src/routes/wpt_history.rs b/src/routes/wpt_history.rs index f5e45a4..079db93 100644 --- a/src/routes/wpt_history.rs +++ b/src/routes/wpt_history.rs @@ -767,120 +767,3 @@ pub fn WptHistorySparklines(history: ArcWptHistory, range: ChartRange) -> Elemen } } } - -#[cfg(test)] -mod tests { - use super::*; - - fn chart_line(totals: &[Option], union_total: u32) -> ChartLine { - ChartLine { - history: ArcWptHistory(Arc::new(WptHistory { - focus_areas: vec!["css".into()], - union_totals: vec![Some(union_total)], - runs: totals - .iter() - .enumerate() - .map(|(i, total)| HistoryRun { - date: format!("2026-01-{:02}", i + 1), - product_revision: format!("rev-{i}"), - commit_message: None, - scores: vec![total.map(|total| (10, 5.0, total, total / 2))], - }) - .collect(), - })), - series: ChartSeries { - area: "css".into(), - label: "Blitz".into(), - color: "#000000", - }, - } - } - - #[test] - fn totals_use_each_runs_count_not_the_union_denominator() { - let line = chart_line(&[Some(100), None, Some(0), Some(200)], 1000); - let totals = area_series( - &line.history, - f64::NEG_INFINITY, - &line.series, - ChartMetric::SubtestTotal, - ) - .unwrap(); - assert_eq!(totals.points.iter().map(|p| p.1).collect::>(), [100.0, 0.0, 200.0]); - let scores = area_series( - &line.history, - f64::NEG_INFINITY, - &line.series, - ChartMetric::PassPercent, - ) - .unwrap(); - assert_eq!(scores.points.iter().map(|p| p.1).collect::>(), [5.0, 10.0]); - - let min_x = parse_date("2026-01-04").unwrap(); - let visible = area_series(&line.history, min_x, &line.series, ChartMetric::SubtestTotal) - .unwrap(); - assert_eq!(visible.points, [(min_x, 200.0)]); - let data = tooltip_runs(&line, min_x, ChartMetric::SubtestTotal, 500.0); - assert_eq!(data["metric"], "total"); - assert_eq!(data["total"], 500.0); - assert_eq!(data["first"], 1); - assert_eq!(data["runs"][0]["v"], serde_json::json!([0, 0])); - assert_eq!(data["runs"][1]["v"], serde_json::json!([100, 200])); - } - - #[test] - fn total_axis_covers_visible_counts_with_integer_ticks() { - for (max, expected) in [(1.0, 5.0), (200.0, 250.0), (1200.0, 2500.0), (1_200_000.0, 2_500_000.0)] { - let series = Series { - color: "#000000", - metric: ChartMetric::SubtestTotal, - points: vec![(0.0, max)], - }; - assert_eq!(subtest_axis_max(&[series]), expected); - } - assert_eq!( - polyline_points(&[(0.0, 100.0), (1.0, 200.0)], 0.0, 1.0, 250.0, (50.0, 25.0, 100.0, 100.0)), - "50.0,85.0 150.0,45.0 " - ); - } - - #[test] - fn comparison_chart_renders_both_metrics_for_each_engine() { - let blitz = chart_line(&[Some(100), Some(200)], 1000); - let mut chrome = chart_line(&[Some(600), Some(800)], 1000); - chrome.series.label = "Chrome".into(); - chrome.series.color = "#e57373"; - let html = dioxus_ssr::render_element(rsx! { - HistoryLineChart { - lines: vec![blitz, chrome], - range: ChartRange::All, - height: 320.0, - show_subtest_totals: true, - } - }); - assert_eq!(html.matches("').unwrap(); - let data: serde_json::Value = serde_json::from_str(json.split("").next().unwrap()).unwrap(); - assert_eq!(data["series"].as_array().unwrap().len(), 4); - assert_eq!(data["series"][0]["metric"], "percent"); - assert_eq!(data["series"][2]["metric"], "total"); - assert_eq!(data["series"][2]["total"], data["series"][3]["total"]); - assert_eq!(data["series"][2]["runs"][1]["v"], serde_json::json!([100, 200])); - assert_eq!(data["series"][3]["runs"][1]["v"], serde_json::json!([400, 800])); - } - - #[test] - fn other_history_charts_remain_percentage_only() { - let line = chart_line(&[Some(100), Some(200)], 1000); - let html = dioxus_ssr::render_element(rsx! { - HistoryLineChart { lines: vec![line], range: ChartRange::All } - }); - assert_eq!(html.matches(" Date: Fri, 2 Oct 2026 00:11:44 +0000 Subject: [PATCH 3/4] Plot a single maximum subtest count across browsers --- src/routes/wpt_compare.rs | 5 ++- src/routes/wpt_history.rs | 75 +++++++++++++++++++++++++++-------- static/wpt-history-tooltip.js | 3 +- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/routes/wpt_compare.rs b/src/routes/wpt_compare.rs index 2c933c6..5036623 100644 --- a/src/routes/wpt_compare.rs +++ b/src/routes/wpt_compare.rs @@ -146,8 +146,9 @@ fn CompareHistoryChart( "Percentage of subtests passing over time, one master run per day. Every engine's percentage is relative to the same denominator: the total number of subtests known to any engine in the latest runs (so lines are not distorted by tests being added - to WPT, and an engine that skips tests is not flattered for it). Dashed lines show - each engine's total number of subtests in each run, using the right-hand axis." + to WPT, and an engine that skips tests is not flattered for it). The dashed line + shows the maximum total subtest count across browsers, using the right-hand axis + and each browser's latest available run at that date." } ChartRangeSelector { current_range: range, diff --git a/src/routes/wpt_history.rs b/src/routes/wpt_history.rs index 079db93..63cbb6b 100644 --- a/src/routes/wpt_history.rs +++ b/src/routes/wpt_history.rs @@ -201,6 +201,52 @@ enum ChartMetric { SubtestTotal, } +fn max_subtest_line(lines: &[ChartLine]) -> ChartLine { + let mut updates = Vec::new(); + for (browser, line) in lines.iter().enumerate() { + let Some(area_idx) = line.history.focus_areas.iter().position(|a| *a == line.series.area) else { + continue; + }; + for run in &line.history.runs { + if let (Some(x), Some(Some((_, _, total, _)))) = + (parse_date(&run.date), run.scores.get(area_idx)) + { + updates.push((x, &run.date, browser, *total)); + } + } + } + updates.sort_by(|a, b| a.0.total_cmp(&b.0)); + + // Carry each browser's most recent count forward between its runs. + let mut totals = vec![0; lines.len()]; + let mut runs = Vec::new(); + for (i, &(x, date, browser, total)) in updates.iter().enumerate() { + totals[browser] = total; + if updates.get(i + 1).is_some_and(|next| next.0 == x) { + continue; + } + let max = totals.iter().copied().max().unwrap_or(0); + runs.push(HistoryRun { + date: date.clone(), + product_revision: String::new(), + commit_message: None, + scores: vec![Some((0, 0.0, max, 0))], + }); + } + ChartLine { + history: ArcWptHistory(Arc::new(WptHistory { + focus_areas: vec![String::new()], + union_totals: vec![None], + runs, + })), + series: ChartSeries { + area: String::new(), + label: "Maximum across browsers".into(), + color: "#666666", + }, + } +} + /// The run identifier shown in a tooltip: browser versions as-is, long /// nightly versions and commit shas shortened fn short_revision(revision: &str) -> &str { @@ -504,17 +550,16 @@ pub fn HistoryLineChart( let (px, py, pw, ph) = plot; let min_x = range.min_x_of(lines.iter().map(|line| &*line.history)); - let metrics = if show_subtest_totals { - &[ChartMetric::PassPercent, ChartMetric::SubtestTotal][..] - } else { - &[ChartMetric::PassPercent][..] - }; - let series: Vec = metrics + let total_line = show_subtest_totals.then(|| max_subtest_line(&lines)); + let chart_lines: Vec<_> = lines .iter() - .flat_map(|&metric| { - lines.iter().filter_map(move |line| { - area_series(&line.history, min_x, &line.series, metric) - }) + .map(|line| (line, ChartMetric::PassPercent)) + .chain(total_line.iter().map(|line| (line, ChartMetric::SubtestTotal))) + .collect(); + let series: Vec = chart_lines + .iter() + .filter_map(|(line, metric)| { + area_series(&line.history, min_x, &line.series, *metric) }) .collect(); let subtest_max = subtest_axis_max(&series); @@ -544,11 +589,9 @@ pub fn HistoryLineChart( "plot": [px, py, pw, ph], "xMin": x_min, "xMax": x_max, - "series": metrics + "series": chart_lines .iter() - .flat_map(|&metric| lines.iter().map(move |line| { - tooltip_runs(line, min_x, metric, subtest_max) - })) + .map(|(line, metric)| tooltip_runs(line, min_x, *metric, subtest_max)) .collect::>(), }) .to_string() @@ -660,7 +703,7 @@ pub fn HistoryLineChart( } } if show_subtest_totals { - for (i, metric) in metrics.iter().enumerate() { + for (i, metric) in [ChartMetric::PassPercent, ChartMetric::SubtestTotal].iter().enumerate() { line { x1: "{px + 10.0 + i as f64 * 220.0}", x2: "{px + 34.0 + i as f64 * 220.0}", @@ -676,7 +719,7 @@ pub fn HistoryLineChart( font_size: "12", fill: "#333", if *metric == ChartMetric::SubtestTotal { - "Total subtests (right axis)" + "Max subtests (right axis)" } else { "Score (left axis)" } diff --git a/static/wpt-history-tooltip.js b/static/wpt-history-tooltip.js index 57cd0ad..9415619 100644 --- a/static/wpt-history-tooltip.js +++ b/static/wpt-history-tooltip.js @@ -144,7 +144,8 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat dot.setAttribute("fill", s.color); dot.style.display = ""; - var html = "
" + esc(run.rev) + " (" + esc(run.d) + ")
"; + var html = "
" + + (run.rev ? esc(run.rev) + " (" + esc(run.d) + ")" : esc(run.d)) + "
"; if (run.msg) { html += "
" + esc(run.msg) + "
"; From 85ed7d3ab0a43f7380b69269a6192893f0c849d2 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Fri, 2 Oct 2026 00:16:51 +0000 Subject: [PATCH 4/4] Use the shared browser score scale for maximum subtests --- src/routes/wpt_compare.rs | 5 ++- src/routes/wpt_history.rs | 75 ++++++----------------------------- static/wpt-history-tooltip.js | 3 +- 3 files changed, 18 insertions(+), 65 deletions(-) diff --git a/src/routes/wpt_compare.rs b/src/routes/wpt_compare.rs index 5036623..086679b 100644 --- a/src/routes/wpt_compare.rs +++ b/src/routes/wpt_compare.rs @@ -147,8 +147,9 @@ fn CompareHistoryChart( percentage is relative to the same denominator: the total number of subtests known to any engine in the latest runs (so lines are not distorted by tests being added to WPT, and an engine that skips tests is not flattered for it). The dashed line - shows the maximum total subtest count across browsers, using the right-hand axis - and each browser's latest available run at that date." + shows the maximum total subtest count across browsers, using the same denominator + and percentage scale as the passing scores, and each browser's latest available + run at that date." } ChartRangeSelector { current_range: range, diff --git a/src/routes/wpt_history.rs b/src/routes/wpt_history.rs index 63cbb6b..212b463 100644 --- a/src/routes/wpt_history.rs +++ b/src/routes/wpt_history.rs @@ -203,10 +203,12 @@ enum ChartMetric { fn max_subtest_line(lines: &[ChartLine]) -> ChartLine { let mut updates = Vec::new(); + let mut subtest_total = None; for (browser, line) in lines.iter().enumerate() { let Some(area_idx) = line.history.focus_areas.iter().position(|a| *a == line.series.area) else { continue; }; + subtest_total = subtest_total.max(line.history.subtest_total(area_idx)); for run in &line.history.runs { if let (Some(x), Some(Some((_, _, total, _)))) = (parse_date(&run.date), run.scores.get(area_idx)) @@ -236,7 +238,7 @@ fn max_subtest_line(lines: &[ChartLine]) -> ChartLine { ChartLine { history: ArcWptHistory(Arc::new(WptHistory { focus_areas: vec![String::new()], - union_totals: vec![None], + union_totals: vec![subtest_total], runs, })), series: ChartSeries { @@ -272,7 +274,7 @@ fn area_series( metric: ChartMetric, ) -> Option { let area_idx = history.focus_areas.iter().position(|a| *a == spec.area)?; - let subtest_total = history.subtest_total(area_idx); + let subtest_total = history.subtest_total(area_idx)?; let points: Vec<(f64, f64)> = history .runs .iter() @@ -281,11 +283,11 @@ fn area_series( parse_date(&run.date)?, match metric { ChartMetric::PassPercent => { - subtest_pass_percent(run, area_idx, subtest_total?)? + subtest_pass_percent(run, area_idx, subtest_total)? } ChartMetric::SubtestTotal => { let (_, _, total, _) = (*run.scores.get(area_idx)?)?; - total as f64 + total as f64 / subtest_total as f64 * 100.0 } }, )) @@ -302,23 +304,6 @@ fn area_series( }) } -fn subtest_axis_max(series: &[Series]) -> f64 { - let max = series - .iter() - .filter(|s| s.metric == ChartMetric::SubtestTotal) - .flat_map(|s| &s.points) - .map(|(_, y)| *y) - .fold(1.0, f64::max); - let raw_step = max / 5.0; - let magnitude = 10.0_f64.powf(raw_step.log10().floor()).max(1.0); - let step = [1.0, 2.0, 5.0, 10.0] - .into_iter() - .find(|step| step * magnitude >= raw_step) - .unwrap_or(10.0) - * magnitude; - step * 5.0 -} - fn polyline_points( points: &[(f64, f64)], x_min: f64, @@ -475,12 +460,7 @@ pub fn WptHistoryChart( /// Per-run tooltip data for one line: the runs from just before the visible /// range onwards, with the line's `[passed, total]` subtest counts -fn tooltip_runs( - line: &ChartLine, - min_x: f64, - metric: ChartMetric, - subtest_max: f64, -) -> serde_json::Value { +fn tooltip_runs(line: &ChartLine, min_x: f64, metric: ChartMetric) -> serde_json::Value { let history = &line.history; let area_idx = history .focus_areas @@ -523,11 +503,7 @@ fn tooltip_runs( "name": line.series.label, "color": line.series.color, "metric": if metric == ChartMetric::SubtestTotal { "total" } else { "percent" }, - "total": if metric == ChartMetric::SubtestTotal { - Some(subtest_max) - } else { - subtest_total.map(f64::from) - }, + "total": subtest_total, "first": first_visible - start, "runs": runs, }) @@ -543,7 +519,7 @@ pub fn HistoryLineChart( ) -> Element { const WIDTH: f64 = 900.0; let plot: (f64, f64, f64, f64) = if show_subtest_totals { - (50.0, 25.0, WIDTH - 135.0, height - 85.0) + (50.0, 15.0, WIDTH - 65.0, height - 75.0) } else { (50.0, 15.0, WIDTH - 65.0, height - 55.0) }; @@ -562,8 +538,6 @@ pub fn HistoryLineChart( area_series(&line.history, min_x, &line.series, *metric) }) .collect(); - let subtest_max = subtest_axis_max(&series); - let x_min = series .iter() .filter_map(|s| s.points.first().map(|p| p.0)) @@ -591,7 +565,7 @@ pub fn HistoryLineChart( "xMax": x_max, "series": chart_lines .iter() - .map(|(line, metric)| tooltip_runs(line, min_x, *metric, subtest_max)) + .map(|(line, metric)| tooltip_runs(line, min_x, *metric)) .collect::>(), }) .to_string() @@ -630,25 +604,6 @@ pub fn HistoryLineChart( } } - if show_subtest_totals { - text { - x: "{px + pw + 6.0}", - y: "{py - 10.0}", - font_size: "12", - fill: "#666", - "Subtests" - } - for i in 0..=5 { - text { - x: "{px + pw + 6.0}", - y: "{py + ph * (1.0 - i as f64 / 5.0) + 4.0}", - font_size: "12", - fill: "#666", - {format!("{:.0}", subtest_max * i as f64 / 5.0)} - } - } - } - // X-axis ticks and labels (month boundaries) for (days, label) in ticks { line { @@ -672,11 +627,7 @@ pub fn HistoryLineChart( // Data series for (i, s) in series.iter().enumerate() { polyline { - points: polyline_points( - &s.points, x_min, x_max, - if s.metric == ChartMetric::SubtestTotal { subtest_max } else { 100.0 }, - plot, - ), + points: polyline_points(&s.points, x_min, x_max, 100.0, plot), fill: "none", stroke: s.color, stroke_width: if i == 0 { "2.5" } else { "1.5" }, @@ -719,9 +670,9 @@ pub fn HistoryLineChart( font_size: "12", fill: "#333", if *metric == ChartMetric::SubtestTotal { - "Max subtests (right axis)" + "Max total subtests" } else { - "Score (left axis)" + "Passing subtests" } } } diff --git a/static/wpt-history-tooltip.js b/static/wpt-history-tooltip.js index 9415619..3f16c3d 100644 --- a/static/wpt-history-tooltip.js +++ b/static/wpt-history-tooltip.js @@ -152,7 +152,8 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat } if (s.metric === "total") { html += "
\u2504 " + - esc(s.name) + ": " + run.v[1].toLocaleString() + " total subtests
"; + esc(s.name) + ": " + (100 * run.v[1] / s.total).toFixed(1) + "% (" + + run.v[1].toLocaleString() + " total subtests)"; if (prev && prev.v != null) { var dTotal = run.v[1] - prev.v[1]; html += "
Change: " + (dTotal > 0 ? "+" : "") +