Skip to content

Commit f06c7af

Browse files
authored
Merge pull request #171 from haskell-graphql/ratchet-test-coverage
Ratchet test coverage
2 parents 5ac071e + 3205e2d commit f06c7af

File tree

2 files changed

+201
-1
lines changed

2 files changed

+201
-1
lines changed

.circleci/config.yml

+12-1
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,19 @@ jobs:
6464
# Build with --pedantic here to avoid introducing warnings. We
6565
# *don't* build with -Werror on Hackage as that is strongly
6666
# discouraged.
67+
#
68+
# Build with --coverage to ratchet our test coverage.
6769
name: Tests
68-
command: STACK_YAML=stack-8.2.yaml stack test --skip-ghc-check --no-terminal --pedantic
70+
command: STACK_YAML=stack-8.2.yaml stack test --skip-ghc-check --no-terminal --pedantic --coverage
71+
- store_artifacts:
72+
path: /root/project/.stack-work/install/x86_64-linux/lts-10.4/8.2.2/hpc
73+
- run:
74+
# There's probably a clever way of separating this from the 8.2 build,
75+
# but I can't be bothered figuring that out right now.
76+
# Thus, tacking the coverage check onto one of the builds,
77+
# arbitrarily picking 8.2 because I feel like it.
78+
name: Coverage
79+
command: STACK_YAML=stack-8.2.yaml ./scripts/hpc-ratchet
6980

7081
workflows:
7182
version: 2

scripts/hpc-ratchet

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/python
2+
"""Ensure our test coverage only increases.
3+
4+
Easier than figuring out how to get hpc-coveralls to work with Stack.
5+
6+
If this fails, and the coverage went down: add some tests.
7+
If this fails, and the coverage went up: edit ``DESIRED_COVERAGE`` to match the new value.
8+
If this succeeds, great.
9+
10+
If you want to get details of what's covered, run::
11+
12+
$ stack test --coverage
13+
14+
And look at the generated HTML.
15+
"""
16+
17+
from __future__ import division
18+
from pprint import pprint
19+
import re
20+
import subprocess
21+
import sys
22+
23+
24+
EXPRESSIONS = 'expressions'
25+
BOOLEANS = 'booleans'
26+
ALTERNATIVES = 'alternatives'
27+
LOCAL_DECLS = 'local_decls'
28+
TOP_LEVEL_DECLS = 'top_level_decls'
29+
30+
31+
"""The lack of coverage we are willing to tolerate.
32+
33+
In a just world, this would be a separate config file, or command-line arguments.
34+
35+
Each item represents the number of "things" we are OK with not being covered.
36+
"""
37+
COVERAGE_TOLERANCE = {
38+
ALTERNATIVES: 175,
39+
BOOLEANS: 8,
40+
EXPRESSIONS: 1494,
41+
LOCAL_DECLS: 15,
42+
TOP_LEVEL_DECLS: 685,
43+
}
44+
45+
46+
def get_report_summary():
47+
"""Run ``stack hpc report --all`` and return the output.
48+
49+
Assumes that ``stack test --coverage`` has already been run.
50+
"""
51+
process = subprocess.Popen(["stack", "hpc", "report", "--all"], stderr=subprocess.PIPE)
52+
stdout, stderr = process.communicate()
53+
return stderr
54+
55+
56+
"""Parse a line from the summary.
57+
58+
Takes a line like:
59+
NN% thingy wotsit used (YYYY/ZZZZ)
60+
61+
And turns it into:
62+
("thingy wotsit used", "YYYY", "ZZZZ")
63+
"""
64+
_summary_line_re = re.compile(r'^\d\d% ([a-z -]+) \((\d+)/(\d+)\)$')
65+
66+
67+
"""Map from the human-readable descriptions to keys in the summary dict."""
68+
_summary_line_entries = {
69+
'expressions used': EXPRESSIONS,
70+
'boolean coverage': BOOLEANS,
71+
'alternatives used': ALTERNATIVES,
72+
'local declarations used': LOCAL_DECLS,
73+
'top-level declarations used': TOP_LEVEL_DECLS,
74+
}
75+
76+
def parse_summary_line(summary_line):
77+
"""Parse a line in the summary that indicates coverage we want to ratchet.
78+
79+
Turns::
80+
81+
NN% thingy wotsit used (YYYY/ZZZZ)
82+
83+
Into::
84+
85+
('thingy', YYYY, ZZZZ)
86+
87+
Returns ``None`` if the line doesn't match the pattern.
88+
"""
89+
match = _summary_line_re.match(summary_line.strip())
90+
if match is None:
91+
return
92+
description, covered, total = match.groups()
93+
try:
94+
key = _summary_line_entries[description] # XXX: Explodes if output changes.
95+
except KeyError:
96+
return
97+
return key, int(covered), int(total)
98+
99+
100+
def parse_report_summary(summary):
101+
"""Parse the output of ``stack hpc report --all``.
102+
103+
Turns this::
104+
105+
Getting project config file from STACK_YAML environment
106+
Generating combined report
107+
57% expressions used (2172/3801)
108+
47% boolean coverage (9/19)
109+
38% guards (5/13), 4 always True, 4 unevaluated
110+
75% 'if' conditions (3/4), 1 unevaluated
111+
50% qualifiers (1/2), 1 always True
112+
45% alternatives used (156/344)
113+
81% local declarations used (70/86)
114+
33% top-level declarations used (348/1052)
115+
The combined report is available at /path/hpc_index.html
116+
117+
Into this::
118+
119+
{'expressions': (2172, 3801),
120+
'booleans': (9, 19),
121+
'alternatives': (156, 344),
122+
'local_decls': (70, 86),
123+
'top_level_decls': (348, 1052),
124+
}
125+
"""
126+
report = {}
127+
for line in summary.splitlines():
128+
parsed = parse_summary_line(line)
129+
if not parsed:
130+
continue
131+
key, covered, total = parsed
132+
report[key] = (covered, total)
133+
return report
134+
135+
136+
def compare_values((covered, total), tolerance):
137+
"""Compare measured coverage values with our tolerated lack of coverage.
138+
139+
Return -1 if coverage has got worse, 0 if it is the same, 1 if it is better.
140+
"""
141+
missing = total - covered
142+
return cmp(tolerance, missing)
143+
144+
145+
def compare_coverage(report, desired):
146+
comparison = {}
147+
for key, actual in report.items():
148+
tolerance = desired.get(key, 0)
149+
if actual:
150+
comparison[key] = compare_values(actual, tolerance)
151+
else:
152+
comparison[key] = None
153+
return comparison
154+
155+
156+
def format_result(result):
157+
if result < 0:
158+
return 'WORSE'
159+
elif result == 0:
160+
return 'OK'
161+
else:
162+
return 'BETTER'
163+
164+
165+
def format_entry(key, result, desired, actual):
166+
covered, total = actual
167+
formatted_result = format_result(result)
168+
# TODO: Align results
169+
if result:
170+
return '%s: %s (%d missing => %d missing)' % (
171+
key, formatted_result, desired, total - covered,
172+
)
173+
else:
174+
return '%s: %s' % (key, formatted_result)
175+
176+
177+
def main():
178+
report = parse_report_summary(get_report_summary())
179+
comparison = compare_coverage(report, COVERAGE_TOLERANCE)
180+
all_same = True
181+
for key, value in sorted(comparison.items()):
182+
if value != 0:
183+
all_same = False
184+
print format_entry(key, value, COVERAGE_TOLERANCE.get(key, 0), report[key])
185+
sys.exit(0 if all_same else 2)
186+
187+
188+
if __name__ == '__main__':
189+
main()

0 commit comments

Comments
 (0)