Skip to content

fix: match the HTML report footer's Total to the server-rendered heading - #2273

Open
DSeaStar wants to merge 5 commits into
coveragepy:mainfrom
DSeaStar:fix-html-footer-rounding-2256
Open

DSeaStar wants to merge 5 commits into
coveragepy:mainfrom
DSeaStar:fix-html-footer-rounding-2256

Conversation

@DSeaStar

Copy link
Copy Markdown

Closes #2256.

The index page recomputes the Total row of the footer in JavaScript, using toFixed(). That has neither of the two guarantees display_covered() provides, so the footer could disagree with the heading rendered by the server directly above it:

  • toFixed() has no 0/100 clamp, so 249/250 displayed as 100% even though a statement never ran, and 4/1000 displayed as 0% even though four statements did.
  • toFixed() rounds ties away from zero, where Python''s round() rounds them to even, so 173/200 was 87% in the footer and 86% in the heading.

A synthetic input event is dispatched on load, so this is not limited to filtered views: the server-rendered footer is overwritten on every page view.

This ports display_covered() to JavaScript (coverage.display_covered) and uses it for the footer, so the two can never disagree. The zero-denominator case still reports 100%, matching Numbers._percent().

On the rounding. Matching Python means matching round-half-to-even, which has to work from the exact value of the double. The obvious implementation scales by 10**places and compares the fraction to 0.5, but that is wrong: 0.15 * 10 rounds to exactly 1.5 in floating point even though the stored value is just below 0.15, inventing a tie that Python does not see. That variant disagreed with Python on 401 of 166,604 cases. round_half_even() here decomposes the double into an exact integer fraction and rounds with BigInt instead, which agrees on all 166,604.

Verification. coverage.display_covered was compared against Python''s display_covered over every (numer, denom, precision) combination for denom up to 150 plus 14 larger values, at precisions 0-3: all 166,604 matched, including the zero-denominator case. QUnit tests were added to tests/js/tests.js covering the cases from the issue plus the same four cases test_results.py uses for display_covered, and were run headlessly.

Note the tests in tests/js/ are browser-only and are not run in CI, so they were executed with a small local harness rather than by the test suite.

One divergence reported in the issue is not addressed here: with --skip-covered, fully covered files add to the heading total but produce no row, so the JS sum of surviving rows is lower than the heading. That is a separate accounting problem in how the totals are accumulated, not a rounding error, and it seemed better kept out of this fix. Happy to open a follow-up if you want it.

The index page recomputes the Total row in JavaScript with toFixed(),
which has neither of the guarantees display_covered() provides, so the
footer could disagree with the heading right above it -- including
showing 100% when coverage was not complete, and 0% when some code did
run.  A synthetic input event is dispatched on load, so this happens on
every page view, not just filtered ones.

Port display_covered() to JavaScript and use it for the footer.  Ties
are rounded to even, as Python's round() does, which means working from
the exact value of the double: scaling in floating point can nudge a
value that is merely close to a tie onto one.  Verified against Python
over 166,604 (numer, denom, precision) combinations, all matching.

Closes coveragepy#2256
The JS test suite was de-jQuery'd in coveragepy#2272, and index.html no longer
loads jQuery.  The display_covered cases added here still used $.each,
so once this is merged onto main the whole suite would fail to load
with "ReferenceError: $ is not defined".  Use Array.prototype.forEach
instead, matching the rest of the file.
@DSeaStar

DSeaStar commented Aug 30, 2026 •

Copy link
Copy Markdown
Author

Sorry about the noise here -- my tooling posted a filename instead of the file's contents. This was meant to be the note below; the PR description covers the same ground.

The footer's Total is recomputed in the browser, so it has to agree with the percentage the server rendered in the heading directly above it. toFixed() gave neither of the two guarantees display_covered() provides:

  • No 0/100 clamp, so 249/250 displayed as 100% even though a statement never ran, and 4/1000 displayed as 0% even though four statements did.
  • Ties round away from zero, where Python's round() rounds them to even, so 173/200 was 87% in the footer and 86% in the heading.

A synthetic input event is dispatched on load, so this is not limited to filtered views: the server-rendered footer is overwritten on every page view.

The fix ports display_covered() to JavaScript and uses it for the footer. Verified against Python's display_covered() over 51,540 (numerator, denominator, precision) combinations: all agree.

One divergence from the issue is not addressed: with --skip-covered, fully covered files add to the heading total but produce no row, so the sum of the surviving rows is lower than the heading. That is an accounting question rather than a rounding bug, so it is left out here. Happy to follow up if you want it.

@nedbat

nedbat commented Aug 30, 2026

Copy link
Copy Markdown
Member

@comment_2273.md

What was this meant to be?

Comment thread coverage/htmlfiles/coverage_html.js Outdated
// an exact tie rounds to the even neighbor, where Number.toFixed() always
// rounds away from zero. The double is split into an exact integer fraction
// first, because scaling in floating point can nudge a value that is merely
// close to a tie onto one, and so change the result.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand all this code, but it seems really complicated for something that is conceptually simple. Are you sure we need all this? I'd rather have a very occasional mismatch than have to maintain this code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question -- you were right to push back, and I've rewritten it. The same function is now:

function round_half_even(value, places) {
    let mantissa = Math.abs(value);
    let exponent = 0;
    while (mantissa !== Math.floor(mantissa)) {
        mantissa *= 2;
        exponent += 1;
    }
    const numerator = BigInt(mantissa) * (10n ** BigInt(places));
    const denominator = 1n << BigInt(exponent);
    let rounded = numerator / denominator;
    const twice_remainder = 2n * (numerator % denominator);
    if (twice_remainder > denominator || (twice_remainder === denominator && (rounded % 2n) === 1n)) {
        rounded += 1n;
    }
    return rounded;
}

No DataView and no bit masks: doubling a double is exact, so the loop recovers the value as m/2**e and the rounding is done on that fraction.

On whether any of it is needed: the clamp half certainly is, and that part is short. For the rounding half, here is what each step buys, measured over 51,540 (numerator, denominator, precision) combinations against Python's display_covered():

  • toFixed() plus the clamps: 215 disagreements. These are ordinary numbers -- 1/8 is 12.5%, which toFixed() shows as 13 and Python shows as 12. Exact ties are not exotic.
  • Half-even, but with the scaling done in floating point first: 13 disagreements. That shortcut looks right and isn't: 0.15 * 10 is exactly 1.5 even though the double nearest 0.15 is a shade below it, so scaling invents ties that Python never sees. Scaling the integers instead collapses the same last bits and lands on the same 13.
  • Rounding the exact value of the double, as above: 0.

If you would still rather not carry it, there is a smaller version: pass the numerator and denominator instead of a percentage, scale with a single integer division, and round with Math.floor plus a remainder === 0.5 test. About ten lines, no BigInt -- and it lands in that middle bucket. The 13 are all cases like 45/10000, which is exactly 0.45%: Python renders it as 0.5 and that version renders it as 0.4. Say the word and I'll switch to it.

round_half_even() used to decode the IEEE-754 bits with a DataView to get
the double's exact value as a fraction before scaling it.  Doubling a
double is exact, so the same m/2**e pair can be recovered with a loop and
no bit twiddling, which is a lot easier to read.

Verified against Python's coverage.results.display_covered() over 51,540
(numer, denom, precision) combinations: all match, as before.
main gained a new Unreleased entry (issue 1563) in the same place, so
CHANGES.rst conflicted.  Both entries are kept, the upstream one first.
No code changes were needed: coverage/htmlfiles/coverage_html.js and
tests/js/tests.js merged cleanly, and the diff against main is still the
same three files.
@DSeaStar

Copy link
Copy Markdown
Author

Branch synced with main, which had landed a new Unreleased entry (issue 1563) in the same spot, so CHANGES.rst conflicted. Both entries are kept, upstream's first. coverage/htmlfiles/coverage_html.js and tests/js/tests.js merged cleanly, and the diff against main is still the same three files (+107/-4).

The round_half_even() rewrite described above is in this range as well. All five checks are green on 6b0e2ff, and the PR is mergeable again.

@DSeaStar

DSeaStar commented Sep 1, 2026

Copy link
Copy Markdown
Author

I only found #2257 today (siaklin99-gif, opened 2026-08-11) — I opened this PR without having seen it, which is on me. Since you asked for a comparison on #2259, here are the measured numbers so you can pick one and I will get out of the way.

All three PRs (#2257, #2259, this one) fix the reported symptom in #2256 and all three are green. They differ on one design decision:

The residual gap in the two "clamp" implementations is tie rounding. display_covered() ends in round(pc, precision), which is half-even; both toFixed() and Math.round() round ties away from zero. Measured against coverage.results.display_covered over numer 0..denom, denom 1..250, precision 0/1/2 — 94,875 cases:

implementation mismatches vs Python
#2257 (clamp + toFixed) 345 (149 / 108 / 88 at precision 0 / 1 / 2)
#2259 (clamp + Math.round + toFixed) 345 (the same cases)
#2273 (clamp + half-even) 0

Two examples: 1/8 → Python 12%, the clamp versions 13%; 5/8 → 62% vs 63%.

For #2257 all 345 can only surface while a filter is hiding rows, since that is the only path where it recomputes at all. #2259 recomputes unconditionally, so there they can also appear on a plain page view.

Given your "I'd rather have a very occasional mismatch than have to maintain this code", #2257 looks like the better trade to me and I would suggest merging that one. I am happy to close this PR, or — if you would rather have the filtered view exact too — to rebase just the tie-rounding piece onto #2257 as a follow-up once it lands. Say which and I will do it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTML index: footer Total is recomputed in JS with toFixed(), can show 100% when coverage isn't 100%

2 participants