Skip to content

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

Description

@siaklin99-gif

Describe the bug

On the HTML report index, the <tfoot> Total percentage is recomputed in
JavaScript with toFixed(), which does not implement the 0/100 clamp that
display_covered() guarantees. The <h1> heading on the same page is rendered
server-side from pc_covered_str and is never touched by JS, so the two can
disagree — including showing 100% when coverage is not complete.

coverage/htmlfiles/coverage_html.js (in filter_handler):

const { numer, denom } = totals[column];
cell.dataset.ratio = `${numer} ${denom}`;
cell.textContent = denom
    ? `${(numer * 100 / denom).toFixed(places)}%`
    : `${(100).toFixed(places)}%`;

This is not limited to filtered views. A few lines below, a synthetic input
event is dispatched on load:

// Trigger change event on setup, to force filter on page refresh
// (filter value may still be present).
document.getElementById("filter").dispatchEvent(new Event("input"));
document.getElementById("hide100").dispatchEvent(new Event("input"));

so the server-rendered footer is overwritten on every page view, with no filter
typed and no checkbox ticked.

Expected behaviour

display_covered() in coverage/results.py documents the invariant:

Note that "0" is only returned when the value is truly zero, and "100" is only
returned when the value is truly 100. Rounding can never result in either "0" or
"100".

The footer should honour the same rule as the heading and the text report.

Actual behaviour

I ran both implementations directly — display_covered() as written in
coverage/results.py, and the JS expression above in node — at the default
precision = 0:

statements covered true % <h1> (Python) Total row (JS)
249 / 250 99.60 99% 100%
199 / 200 99.50 99% 100%
999 / 1000 99.90 99% 100%
173 / 200 86.50 86% 87%
4 / 1000 0.40 1% 0%

The first three are the invariant being broken: the footer reads 100% while one
statement was never executed. The last is the mirror case — 0% while four
statements did run. The 86 / 87 row is an independent divergence: Python's
round() is banker's rounding, toFixed() rounds ties away from zero, so ordinary
values can differ by one without any clamp being involved.

For a single-file project the disagreement is between a row and the Total directly
beneath it.

Additionally

With --skip-covered, should_report() adds a file's numbers to
index_page.totals before the skip returns, but fully-covered files produce no
<tr>. The JS sums only the surviving rows, so the heading and the footer diverge
further — e.g. a.py 10/10 and b.py 10/20 gives a heading of 67% over a footer
of 50%. (I traced this in the source but did not run it, so treat it as secondary
to the rounding issue above.)

Note

grep -E "filter_handler|totals|hide100" tests/js/tests.js returns no matches, so
this path appears to be untested.

Versions

  • coverage 7.16.0a0, master @ e14c88af87a7d071cc3920b9948f0b4794b44d2c
  • Found by reading the source and by running the two rounding implementations
    side by side; I have not generated a live HTML report to confirm on screen.

Happy to open a PR routing the footer through the same clamp if that's welcome.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions