path_coverage 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +287 -78
- data/lib/path_coverage/calls.rb +54 -15
- data/lib/path_coverage/condition.rb +25 -2
- data/lib/path_coverage/configuration.rb +44 -7
- data/lib/path_coverage/configuration_paths.rb +56 -0
- data/lib/path_coverage/console_summary.rb +77 -0
- data/lib/path_coverage/gaps.rb +25 -9
- data/lib/path_coverage/html/index.html +3 -17
- data/lib/path_coverage/html/side.js +21 -2
- data/lib/path_coverage/html/sort.js +24 -21
- data/lib/path_coverage/html/style.css +28 -1
- data/lib/path_coverage/html/tabs.js +15 -0
- data/lib/path_coverage/html_index.rb +86 -31
- data/lib/path_coverage/html_index_head.rb +49 -0
- data/lib/path_coverage/html_report.rb +30 -13
- data/lib/path_coverage/html_tabs.rb +26 -0
- data/lib/path_coverage/html_tree.rb +41 -20
- data/lib/path_coverage/json_reader.rb +94 -0
- data/lib/path_coverage/json_writer.rb +41 -9
- data/lib/path_coverage/kernel.rb +42 -20
- data/lib/path_coverage/kinds/base.rb +71 -0
- data/lib/path_coverage/kinds/block.rb +136 -0
- data/lib/path_coverage/kinds/case.rb +89 -0
- data/lib/path_coverage/kinds/chain.rb +45 -0
- data/lib/path_coverage/kinds/choice.rb +68 -0
- data/lib/path_coverage/kinds/contains.rb +111 -0
- data/lib/path_coverage/kinds/safe.rb +151 -0
- data/lib/path_coverage/kinds/write.rb +54 -0
- data/lib/path_coverage/kinds.rb +37 -0
- data/lib/path_coverage/manifest.rb +58 -19
- data/lib/path_coverage/manifest_blocks.rb +49 -10
- data/lib/path_coverage/manifest_chains.rb +76 -0
- data/lib/path_coverage/manifest_choices.rb +30 -17
- data/lib/path_coverage/manifest_folding.rb +91 -0
- data/lib/path_coverage/manifest_ignoring.rb +35 -0
- data/lib/path_coverage/manifest_writes.rb +67 -0
- data/lib/path_coverage/merge.rb +19 -3
- data/lib/path_coverage/post.rb +8 -8
- data/lib/path_coverage/recorder.rb +9 -49
- data/lib/path_coverage/recorder_blocks.rb +68 -0
- data/lib/path_coverage/recorder_cases.rb +23 -0
- data/lib/path_coverage/recorder_chains.rb +40 -0
- data/lib/path_coverage/recorder_writes.rb +26 -0
- data/lib/path_coverage/render.rb +58 -46
- data/lib/path_coverage/result.rb +19 -0
- data/lib/path_coverage/rewriter.rb +58 -77
- data/lib/path_coverage/rewriter_nested.rb +62 -0
- data/lib/path_coverage/site_paths.rb +9 -58
- data/lib/path_coverage/site_tree.rb +12 -57
- data/lib/path_coverage/summary.rb +32 -4
- data/lib/path_coverage/totals.rb +1 -1
- data/lib/path_coverage/version.rb +1 -1
- metadata +30 -6
|
@@ -1,21 +1,30 @@
|
|
|
1
|
+
require_relative 'configuration_paths'
|
|
2
|
+
|
|
1
3
|
module PathCoverage
|
|
2
4
|
# Raised for a start block that cannot be applied.
|
|
3
5
|
class ConfigurationError < StandardError; end
|
|
4
6
|
|
|
5
7
|
# The settings given in a PathCoverage.start block.
|
|
6
8
|
class Configuration
|
|
9
|
+
include ConfigurationPaths
|
|
10
|
+
|
|
7
11
|
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
8
12
|
SETTINGS = %w[
|
|
9
|
-
collector cover coverage_dir measure report root
|
|
13
|
+
collector cover coverage_dir group ignore measure report root
|
|
14
|
+
skip treat
|
|
10
15
|
].freeze
|
|
11
|
-
REPORTERS = %w[gaps html post].freeze
|
|
16
|
+
REPORTERS = %w[gaps html post summary].freeze
|
|
12
17
|
MEASURES = %w[decisions methods].freeze
|
|
13
18
|
|
|
14
19
|
# Evaluates the settings block against a new configuration.
|
|
15
20
|
def initialize(&)
|
|
16
21
|
@globs = []
|
|
22
|
+
@skips = []
|
|
23
|
+
@groups = {}
|
|
17
24
|
@reporters = [:gaps]
|
|
18
25
|
@measures = [:decisions]
|
|
26
|
+
@ignored = []
|
|
27
|
+
@treats = []
|
|
19
28
|
instance_eval(&)
|
|
20
29
|
end
|
|
21
30
|
|
|
@@ -29,7 +38,7 @@ module PathCoverage
|
|
|
29
38
|
|
|
30
39
|
# Chooses what is measured.
|
|
31
40
|
# Eg measure :decisions, :methods also measures the paths
|
|
32
|
-
#
|
|
41
|
+
# within each method, through its decisions.
|
|
33
42
|
# Refuses a name there is no measure for.
|
|
34
43
|
def measure(*names)
|
|
35
44
|
check('measure', names, MEASURES)
|
|
@@ -60,6 +69,23 @@ module PathCoverage
|
|
|
60
69
|
@globs << glob
|
|
61
70
|
end
|
|
62
71
|
|
|
72
|
+
# Adds a glob, relative to root, of files not to instrument,
|
|
73
|
+
# though a cover glob matches them, eg skip 'source/vendor/**'.
|
|
74
|
+
def skip(glob)
|
|
75
|
+
@skips << glob
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# { name => [glob, ...] } of each group given, in order.
|
|
79
|
+
# Eg { 'Models' => ['source/models/**/*.rb'] }.
|
|
80
|
+
attr_reader :groups
|
|
81
|
+
|
|
82
|
+
# Names a group of covered files, those matching any of
|
|
83
|
+
# globs, relative to root, so the HTML index gives them a
|
|
84
|
+
# tab of their own, eg group 'Models', 'source/models/**'.
|
|
85
|
+
def group(name, *globs)
|
|
86
|
+
@groups[name] = globs
|
|
87
|
+
end
|
|
88
|
+
|
|
63
89
|
# Chooses the reporters run at exit, eg report :gaps;
|
|
64
90
|
# refuses a name there is no reporter for.
|
|
65
91
|
def report(*names)
|
|
@@ -86,10 +112,11 @@ module PathCoverage
|
|
|
86
112
|
@coverage_dir = dir
|
|
87
113
|
end
|
|
88
114
|
|
|
89
|
-
# Every file the cover globs match under root,
|
|
90
|
-
# paths, whether or not the run loads it.
|
|
115
|
+
# Every file the cover globs match under root, and no skip
|
|
116
|
+
# glob does, as full paths, whether or not the run loads it.
|
|
91
117
|
def files
|
|
92
|
-
Dir.glob(@globs, base: @root).sort
|
|
118
|
+
found = Dir.glob(@globs, base: @root).sort
|
|
119
|
+
found.reject { |f| skipped?(f) }.map { |f| "#{@root}/#{f}" }
|
|
93
120
|
end
|
|
94
121
|
|
|
95
122
|
# Where the JSON report goes.
|
|
@@ -102,13 +129,23 @@ module PathCoverage
|
|
|
102
129
|
path.start_with?("#{@root}/") && matches?(relative(path))
|
|
103
130
|
end
|
|
104
131
|
|
|
105
|
-
# True when the root-relative path matches a cover glob
|
|
132
|
+
# True when the root-relative path matches a cover glob and
|
|
133
|
+
# no skip glob.
|
|
106
134
|
def matches?(relative_path)
|
|
135
|
+
return false if skipped?(relative_path)
|
|
136
|
+
|
|
107
137
|
@globs.any? do |glob|
|
|
108
138
|
File.fnmatch?(glob, relative_path, GLOB_FLAGS)
|
|
109
139
|
end
|
|
110
140
|
end
|
|
111
141
|
|
|
142
|
+
# True when the root-relative path matches a skip glob.
|
|
143
|
+
def skipped?(relative_path)
|
|
144
|
+
@skips.any? do |glob|
|
|
145
|
+
File.fnmatch?(glob, relative_path, GLOB_FLAGS)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
112
149
|
# path relative to root, eg "source/kata.rb".
|
|
113
150
|
def relative(path)
|
|
114
151
|
path.delete_prefix("#{@root}/")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require_relative 'kinds'
|
|
2
|
+
|
|
3
|
+
module PathCoverage
|
|
4
|
+
# The settings of a PathCoverage.start block that change which
|
|
5
|
+
# paths a site has: ignore, eg ignore '&&=', and treat, eg
|
|
6
|
+
# treat 'name', as: :present. It relies on its includer's
|
|
7
|
+
# @ignored, @treats and check.
|
|
8
|
+
module ConfigurationPaths
|
|
9
|
+
TREATMENTS = %w[present nonempty].freeze
|
|
10
|
+
|
|
11
|
+
# The operators whose sites are not measured, chosen with
|
|
12
|
+
# ignore, eg ['&&=']; none when nothing was chosen.
|
|
13
|
+
attr_reader :ignored
|
|
14
|
+
|
|
15
|
+
# Chooses operators whose sites are not measured, eg
|
|
16
|
+
# ignore '&&=' measures no &&= write; a site inside one, eg
|
|
17
|
+
# on its right side, still is. Refuses an operator there
|
|
18
|
+
# is no kind of site for.
|
|
19
|
+
def ignore(*operators)
|
|
20
|
+
check('operator', operators, Kinds::BY_OPERATOR.keys)
|
|
21
|
+
@ignored = operators
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Treats calls to the method named name as never nil, with
|
|
25
|
+
# as: :present, so a &. after such a call has no nil path;
|
|
26
|
+
# or as true exactly when its receiver has items, with as:
|
|
27
|
+
# :nonempty, eg any?. It applies in each covered file
|
|
28
|
+
# matching glob, relative to root, or in every one with no
|
|
29
|
+
# glob. Refuses any other treatment.
|
|
30
|
+
def treat(name, as:, in: nil)
|
|
31
|
+
check('treatment', [as], TREATMENTS)
|
|
32
|
+
@treats << [name, as.to_s, binding.local_variable_get(:in)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The names of the methods treated as as, eg 'present', in
|
|
36
|
+
# the covered file at relative_path.
|
|
37
|
+
def treated(as, relative_path)
|
|
38
|
+
flags = Configuration::GLOB_FLAGS
|
|
39
|
+
@treats.filter_map do |name, treatment, glob|
|
|
40
|
+
next unless treatment == as
|
|
41
|
+
|
|
42
|
+
name if glob.nil? || File.fnmatch?(glob, relative_path, flags)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The settings that change which paths a site has, eg
|
|
47
|
+
# { 'ignored' => ['&&='],
|
|
48
|
+
# 'treats' => [['name', 'present', nil]] },
|
|
49
|
+
# each only when set: runs whose fingerprints differ list
|
|
50
|
+
# their sites' paths differently.
|
|
51
|
+
def fingerprint
|
|
52
|
+
all = { 'ignored' => @ignored, 'treats' => @treats }
|
|
53
|
+
all.reject { |_, setting| setting.empty? }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require_relative 'summary'
|
|
2
|
+
require_relative 'totals'
|
|
3
|
+
|
|
4
|
+
module PathCoverage
|
|
5
|
+
# The :summary reporter's lines: each total as SimpleCov prints
|
|
6
|
+
# its line and branch coverage, eg
|
|
7
|
+
# "Path coverage within decisions: 519 / 623 (83.31%)". With
|
|
8
|
+
# groups configured each group is a heading, its own totals
|
|
9
|
+
# indented under it, and there is no total over every group.
|
|
10
|
+
module ConsoleSummary
|
|
11
|
+
# The summary lines of site_paths and, when paths are given,
|
|
12
|
+
# as they are when methods are measured, of methods; groups
|
|
13
|
+
# is { name => [glob, ...] }.
|
|
14
|
+
def self.lines(site_paths, methods, paths, groups = {})
|
|
15
|
+
if groups.empty?
|
|
16
|
+
return totals_lines(totals(site_paths, methods, paths))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
all = group_totals(site_paths, methods, paths, groups)
|
|
20
|
+
all.flat_map do |name, totals|
|
|
21
|
+
["#{name}:", *totals_lines(totals).map { |line| " #{line}" }]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# { 'decisions' => [taken, possible] } of site_paths, with
|
|
26
|
+
# 'methods' => [taken, possible] of methods when paths are
|
|
27
|
+
# given.
|
|
28
|
+
def self.totals(site_paths, methods, paths)
|
|
29
|
+
totals = { 'decisions' => Totals.decisions(site_paths) }
|
|
30
|
+
return totals if paths.nil?
|
|
31
|
+
|
|
32
|
+
totals.merge('methods' => Totals.methods(methods, paths))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# { group name => totals } of each group's own sites and
|
|
36
|
+
# methods alone.
|
|
37
|
+
def self.group_totals(site_paths, methods, paths, groups)
|
|
38
|
+
groups.to_h do |name, globs|
|
|
39
|
+
own_sites = own(site_paths, globs)
|
|
40
|
+
[name, totals(own_sites, own(methods, globs), paths)]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The entries of sites, keyed by site name, in a file matching
|
|
45
|
+
# any of globs.
|
|
46
|
+
def self.own(sites, globs)
|
|
47
|
+
sites.select do |site, _|
|
|
48
|
+
Summary.matches?(Summary.path_of(site), globs)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The line naming the operators ignored, eg "Path coverage
|
|
53
|
+
# ignores: &&="; none when none are.
|
|
54
|
+
def self.ignored_lines(ignored)
|
|
55
|
+
return [] if ignored.empty?
|
|
56
|
+
|
|
57
|
+
["Path coverage ignores: #{ignored.join(', ')}"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A line per total, eg "Path coverage within methods: 1 / 4
|
|
61
|
+
# (25.00%)".
|
|
62
|
+
def self.totals_lines(totals)
|
|
63
|
+
totals.map do |name, pair|
|
|
64
|
+
"Path coverage within #{name}: #{figure(pair)}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# [taken, possible] as "taken / possible (percent)", its
|
|
69
|
+
# percent to two places, or - when no path is possible.
|
|
70
|
+
def self.figure((taken, possible))
|
|
71
|
+
return "#{taken} / #{possible} (-)" if possible.zero?
|
|
72
|
+
|
|
73
|
+
percent = format('%.2f%%', 100.0 * taken / possible)
|
|
74
|
+
"#{taken} / #{possible} (#{percent})"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/path_coverage/gaps.rb
CHANGED
|
@@ -4,28 +4,44 @@ module PathCoverage
|
|
|
4
4
|
# count stays in the operands and paths for reporters that
|
|
5
5
|
# show them all.
|
|
6
6
|
module Gaps
|
|
7
|
-
# The gap lines of operands and
|
|
8
|
-
# them, in manifest order; texts is { site => each
|
|
9
|
-
# source text }.
|
|
10
|
-
def self.lines(operands, paths: {}, texts: {})
|
|
7
|
+
# The gap lines of operands, paths and outside, as Result
|
|
8
|
+
# gives them, in manifest order; texts is { site => each
|
|
9
|
+
# leaf's source text }.
|
|
10
|
+
def self.lines(operands, paths: {}, texts: {}, outside: {})
|
|
11
11
|
operands.flat_map do |site, counts|
|
|
12
12
|
if counts.all? { |each| never_ran?(each) }
|
|
13
13
|
next ["#{site} never reached"]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
path_gaps(site, paths.fetch(site, []), texts[site])
|
|
16
|
+
path_gaps(site, paths.fetch(site, []), texts[site]) +
|
|
17
|
+
ruled_out(site, outside.fetch(site, {}), texts[site])
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# One line per path site took that a treatment rules out,
|
|
22
|
+
# as Result#outside gives them, texts being each leaf's
|
|
23
|
+
# source text.
|
|
24
|
+
def self.ruled_out(site, taken, texts)
|
|
25
|
+
taken.keys.map do |path|
|
|
26
|
+
"#{site} path taken that a treatment rules out: " \
|
|
27
|
+
"#{steps(path, texts)}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The outcome of each leaf path reaches, each named by its
|
|
32
|
+
# text, eg "`u.name` nil".
|
|
33
|
+
def self.steps(path, texts)
|
|
34
|
+
path.zip(texts).filter_map do |outcome, text|
|
|
35
|
+
"#{code(text)} #{outcome}" unless outcome.nil?
|
|
36
|
+
end.join(', ')
|
|
37
|
+
end
|
|
38
|
+
|
|
20
39
|
# One line per path site never took, as Result#paths gives
|
|
21
40
|
# them, naming the outcome of each leaf the path reaches,
|
|
22
41
|
# texts being each leaf's source text.
|
|
23
42
|
def self.path_gaps(site, paths, texts)
|
|
24
43
|
paths.select { |each| each[:count].zero? }.map do |each|
|
|
25
|
-
|
|
26
|
-
"#{code(text)} #{outcome}" unless outcome.nil?
|
|
27
|
-
end
|
|
28
|
-
"#{site} path never taken: #{steps.join(', ')}"
|
|
44
|
+
"#{site} path never taken: #{steps(each[:path], texts)}"
|
|
29
45
|
end
|
|
30
46
|
end
|
|
31
47
|
|
|
@@ -13,24 +13,10 @@
|
|
|
13
13
|
<body>
|
|
14
14
|
<main class="page">
|
|
15
15
|
<h1>Path coverage</h1>
|
|
16
|
+
{{tabs}}
|
|
16
17
|
<div class="box">
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<tr>
|
|
20
|
-
<th aria-sort="none"><button type="button" data-column="0" data-type="text">File</button></th>
|
|
21
|
-
<th class="num" aria-sort="none"><button type="button" data-column="1" data-type="number">Sites</button></th>
|
|
22
|
-
<th class="num" aria-sort="none"><button type="button" data-column="2" data-type="number">Within each decision</button></th>
|
|
23
|
-
{{across}}
|
|
24
|
-
</tr>
|
|
25
|
-
</thead>
|
|
26
|
-
<tbody>
|
|
27
|
-
{{rows}}
|
|
28
|
-
</tbody>
|
|
29
|
-
<tfoot>
|
|
30
|
-
{{total}}
|
|
31
|
-
</tfoot>
|
|
32
|
-
</table>
|
|
33
|
-
</div>
|
|
18
|
+
{{tables}}
|
|
19
|
+
</div>{{ignored}}
|
|
34
20
|
</main>
|
|
35
21
|
<script>
|
|
36
22
|
//<![CDATA[
|
|
@@ -56,10 +56,12 @@
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Opens panel, a tree or a grid, alone; null closes all. Any
|
|
59
|
-
// path shown or pinned goes with the grid it was in.
|
|
59
|
+
// path shown or pinned goes with the grid it was in. The box
|
|
60
|
+
// drops any height a tree it held gave it.
|
|
60
61
|
function openOnly(panel) {
|
|
61
62
|
pin(null);
|
|
62
63
|
clear();
|
|
64
|
+
box.style.minHeight = '';
|
|
63
65
|
trees.forEach(function (tree) {
|
|
64
66
|
tree.hidden = tree !== panel;
|
|
65
67
|
buttonOf(tree).setAttribute('aria-expanded', tree === panel ? 'true' : 'false');
|
|
@@ -70,10 +72,24 @@
|
|
|
70
72
|
bands[i].classList.toggle('open', grid === panel);
|
|
71
73
|
});
|
|
72
74
|
var side = !panel ? '' : panel.classList.contains('tree') ? 'cc' : 'pc';
|
|
75
|
+
fitRail(side === 'cc' ? panel : null);
|
|
73
76
|
document.body.setAttribute('data-open', side);
|
|
74
77
|
slide(panel);
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
// The narrowest the condition rail opens to, as style.css has it.
|
|
81
|
+
var RAIL_MIN = 560;
|
|
82
|
+
|
|
83
|
+
// Widens the condition rail to tree's own width, so a tree with
|
|
84
|
+
// many paths or long labels sits wholly beside the code; null,
|
|
85
|
+
// for no tree open, gives the rail back its own width.
|
|
86
|
+
function fitRail(tree) {
|
|
87
|
+
var style = document.body.style;
|
|
88
|
+
if (!tree) { style.removeProperty('--cc-rail'); return; }
|
|
89
|
+
var width = Math.max(RAIL_MIN, Math.ceil(tree.offsetWidth));
|
|
90
|
+
style.setProperty('--cc-rail', width + 'px');
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
// Scrolls the page just enough to show all of panel when it
|
|
78
94
|
// is a tree, eg one opened from a line near the bottom of the
|
|
79
95
|
// window, so its last rows are not below the fold. Its first
|
|
@@ -101,13 +117,16 @@
|
|
|
101
117
|
|
|
102
118
|
// Sets an open tree's top so its first site's row, naming its
|
|
103
119
|
// operator and path counts, is level with the first line of
|
|
104
|
-
// its site's code.
|
|
120
|
+
// its site's code. The box grows to hold the whole tree, as
|
|
121
|
+
// one overflowing it would make the box scroll, sliding the
|
|
122
|
+
// code's first lines out of sight.
|
|
105
123
|
function placeTree(tree) {
|
|
106
124
|
var block = buttonOf(tree).closest('.block');
|
|
107
125
|
tree.style.top = '0px';
|
|
108
126
|
var line = spot(block.querySelector('.src'), ccRail).middle;
|
|
109
127
|
var row = spot(tree.querySelector('.site .row'), tree).middle;
|
|
110
128
|
tree.style.top = (line - row) + 'px';
|
|
129
|
+
box.style.minHeight = Math.ceil(spot(tree, box).bottom) + 'px';
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
// Sets an open grid's top level with its def, its calls row
|
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
// Sorts
|
|
1
|
+
// Sorts a files table by the clicked column's data-sort values;
|
|
2
2
|
// a second click on the same column reverses the order. A cell
|
|
3
3
|
// with an empty data-sort, one with no value such as a -, stays
|
|
4
4
|
// last in both orders, so either end of the values can be first.
|
|
5
|
+
// Each table, one per tab, sorts on its own.
|
|
5
6
|
(function () {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
document.querySelectorAll('table.files').forEach(function (table) {
|
|
8
|
+
var body = table.tBodies[0];
|
|
9
|
+
var state = { column: -1, ascending: true };
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
table.querySelectorAll('thead th').forEach(function (th, i) {
|
|
11
|
+
table.querySelectorAll('thead button').forEach(function (button) {
|
|
12
|
+
button.addEventListener('click', function () {
|
|
13
|
+
var column = Number(button.dataset.column);
|
|
14
|
+
var numeric = button.dataset.type === 'number';
|
|
15
|
+
state.ascending = state.column === column ? !state.ascending : true;
|
|
16
|
+
state.column = column;
|
|
17
|
+
var rows = Array.from(body.rows);
|
|
18
|
+
rows.sort(function (a, b) {
|
|
19
|
+
var x = a.cells[column].dataset.sort;
|
|
20
|
+
var y = b.cells[column].dataset.sort;
|
|
21
|
+
if (x === '' || y === '') { return (x === '') - (y === ''); }
|
|
22
|
+
var order = numeric ? Number(x) - Number(y) : x.localeCompare(y);
|
|
23
|
+
return state.ascending ? order : -order;
|
|
24
|
+
});
|
|
25
|
+
rows.forEach(function (row) { body.appendChild(row); });
|
|
26
26
|
var sort = state.ascending ? 'ascending' : 'descending';
|
|
27
|
-
|
|
27
|
+
table.querySelectorAll('thead button').forEach(function (other) {
|
|
28
|
+
var mine = Number(other.dataset.column) === column;
|
|
29
|
+
other.parentNode.setAttribute('aria-sort', mine ? sort : 'none');
|
|
30
|
+
});
|
|
28
31
|
});
|
|
29
32
|
});
|
|
30
33
|
});
|
|
@@ -50,7 +50,22 @@ td:first-child { font-family: var(--font-code); }
|
|
|
50
50
|
head, so it reads as a sum, not as one more file. */
|
|
51
51
|
tfoot td { background: var(--panel); font-weight: 600; border-top: 1px solid var(--fg); }
|
|
52
52
|
tfoot td:first-child { font-family: inherit; }
|
|
53
|
+
/* The index page's tabs, one per group of files; the selected
|
|
54
|
+
one framed as the table's headings are. */
|
|
55
|
+
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; }
|
|
56
|
+
.tabs [role="tab"] { font: inherit; font-size: 13px; padding: 6px 12px; border: 1px solid var(--rule); border-radius: 6px; background: var(--surface); color: var(--muted); cursor: pointer; }
|
|
57
|
+
.tabs [role="tab"][aria-selected="true"] { background: var(--panel); color: var(--fg); border-color: var(--fg); font-weight: 600; }
|
|
58
|
+
.tabs [role="tab"]:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
|
|
53
59
|
.num, td:not(:first-child) { text-align: right; }
|
|
60
|
+
/* A total's taken, / of and % sit close, so a row still reads
|
|
61
|
+
3 / 4 75%; its name heads all three. The File column takes
|
|
62
|
+
the table's spare width, so it never spreads them apart. */
|
|
63
|
+
table.files tr > :first-child { width: 100%; }
|
|
64
|
+
table.files th, table.files td:not(:first-child) { white-space: nowrap; }
|
|
65
|
+
th.group { text-align: center; padding-bottom: 0; }
|
|
66
|
+
.taken { text-align: right; padding-left: 32px; padding-right: 4px; }
|
|
67
|
+
td.of, th.of { text-align: left; padding-left: 0; padding-right: 6px; }
|
|
68
|
+
.percent { text-align: right; padding-left: 6px; }
|
|
54
69
|
th button {
|
|
55
70
|
font: 600 13px var(--font-body); color: var(--fg); background: none; border: 0;
|
|
56
71
|
padding: 4px 0; cursor: pointer;
|
|
@@ -203,6 +218,8 @@ body[data-layout="beside"] .box:has(> .code) {
|
|
|
203
218
|
body[data-layout="beside"] .code {
|
|
204
219
|
min-width: 0; overflow-x: auto;
|
|
205
220
|
display: grid; grid-template-columns: minmax(max-content, 1fr);
|
|
221
|
+
/* Lines keep their height when the box grows to hold a tree. */
|
|
222
|
+
align-content: start;
|
|
206
223
|
}
|
|
207
224
|
body[data-layout="beside"] .box:has(> .code)::before {
|
|
208
225
|
left: calc(var(--cc-rail) + 38px); right: calc(var(--pc-rail) + 12px);
|
|
@@ -217,7 +234,17 @@ body[data-layout="beside"] .box:has(> .code)::before {
|
|
|
217
234
|
/* The rail's tree has narrower columns, to fit its width; a
|
|
218
235
|
thin rule under each site's counts row, across the tree's
|
|
219
236
|
full width through its 16px side padding. */
|
|
220
|
-
|
|
237
|
+
/* Each row lays its branch and label out at their own width, the
|
|
238
|
+
label pushing its cells to the row's right edge, so a site's
|
|
239
|
+
cells line up in columns however long or deep its labels. */
|
|
240
|
+
.rail .row { display: flex; align-items: center; gap: 8px; }
|
|
241
|
+
.rail .row > :is(.text, .arm, .operator) { margin-right: auto; padding-right: 8px; }
|
|
242
|
+
/* An explicit width, not just a flex basis, so a row's natural
|
|
243
|
+
width counts each cell as 26px, not as its one letter. */
|
|
244
|
+
.rail .row > :is(.cell, .count) { flex: none; width: 26px; }
|
|
245
|
+
/* The tree is as wide as its widest row, and at least the rail;
|
|
246
|
+
side.js widens the rail to it. */
|
|
247
|
+
.rail .tree { width: max-content; min-width: 100%; right: auto; }
|
|
221
248
|
.rail .site > .row:first-child {
|
|
222
249
|
border-bottom: 1px solid var(--tree-rule); margin: 0 -16px 6px; padding: 0 16px 4px;
|
|
223
250
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Switches the index page's tabs: a click on a tab selects it and
|
|
2
|
+
// shows the table it controls, hiding every other tab's table.
|
|
3
|
+
(function () {
|
|
4
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
5
|
+
tabs.forEach(function (tab) {
|
|
6
|
+
tab.addEventListener('click', function () {
|
|
7
|
+
tabs.forEach(function (other) {
|
|
8
|
+
var selected = other === tab;
|
|
9
|
+
other.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
10
|
+
var table = document.getElementById(other.getAttribute('aria-controls'));
|
|
11
|
+
table.hidden = !selected;
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
})();
|
|
@@ -1,29 +1,76 @@
|
|
|
1
1
|
require 'cgi/escape'
|
|
2
|
+
require_relative 'html_index_head'
|
|
3
|
+
require_relative 'html_tabs'
|
|
2
4
|
require_relative 'html_template'
|
|
3
5
|
|
|
4
6
|
module PathCoverage
|
|
5
7
|
# The HTML index page: one sortable row per covered file,
|
|
6
|
-
# each linking to that file's own page, then a Total row
|
|
8
|
+
# each linking to that file's own page, then a Total row; with
|
|
9
|
+
# groups, one such table per tab.
|
|
7
10
|
module HtmlIndex
|
|
8
|
-
# The whole index page
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
11
|
+
# The whole index page. rows are Summary.files rows and total
|
|
12
|
+
# their Summary.total. groups are [name, rows, total] for
|
|
13
|
+
# each group, each given a tab after All files; with none
|
|
14
|
+
# there is no tab bar, just the one table. Any operators
|
|
15
|
+
# ignored are named after the tables.
|
|
16
|
+
def self.page(rows, total, groups = [], ignored: [])
|
|
17
|
+
tables = [['All files', rows, total], *groups]
|
|
18
|
+
names = tables.map(&:first)
|
|
12
19
|
HtmlTemplate.fill(
|
|
13
20
|
HtmlTemplate.asset('index.html'),
|
|
14
21
|
'style' => HtmlTemplate.asset('style.css'),
|
|
15
|
-
'script' =>
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
22
|
+
'script' => script(groups),
|
|
23
|
+
'tabs' => groups.empty? ? '' : HtmlTabs.bar(names),
|
|
24
|
+
'tables' => tables_of(tables),
|
|
25
|
+
'ignored' => ignored_line(ignored)
|
|
19
26
|
)
|
|
20
27
|
end
|
|
21
28
|
|
|
22
|
-
# The
|
|
23
|
-
# are
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
# The paragraph naming the operators ignored, eg
|
|
30
|
+
# "Ignored: &&="; empty when none are.
|
|
31
|
+
def self.ignored_line(ignored)
|
|
32
|
+
return '' if ignored.empty?
|
|
33
|
+
|
|
34
|
+
text = CGI.escapeHTML("Ignored: #{ignored.join(', ')}")
|
|
35
|
+
%(<p class="ignored" data-ignored="">#{text}</p>)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Each of tables, [name, rows, total], as the table of its
|
|
39
|
+
# tab.
|
|
40
|
+
def self.tables_of(tables)
|
|
41
|
+
tables.each_with_index.map do |(_, rows, total), index|
|
|
42
|
+
table(index, rows, total)
|
|
43
|
+
end.join("\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The page's script: its column sorting, and, with groups,
|
|
47
|
+
# its tab switching.
|
|
48
|
+
def self.script(groups)
|
|
49
|
+
sort = HtmlTemplate.asset('sort.js')
|
|
50
|
+
groups.empty? ? sort : sort + HtmlTemplate.asset('tabs.js')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The index-th tab's table of rows, total as its footer; any
|
|
54
|
+
# but the first starts hidden.
|
|
55
|
+
def self.table(index, rows, total)
|
|
56
|
+
body = rows.map { |row| row(row) }.join("\n")
|
|
57
|
+
head = HtmlIndexHead.of(total[:methods])
|
|
58
|
+
[
|
|
59
|
+
open_tag(index),
|
|
60
|
+
"<thead>\n#{head}\n</thead>",
|
|
61
|
+
"<tbody>\n#{body}\n</tbody>",
|
|
62
|
+
"<tfoot>\n#{total_row(total)}\n</tfoot>",
|
|
63
|
+
'</table>'
|
|
64
|
+
].join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The opening tag of the index-th tab's table, which the tab
|
|
68
|
+
# of the same index controls; any but the first is hidden.
|
|
69
|
+
def self.open_tag(index)
|
|
70
|
+
hidden = index.zero? ? '' : ' hidden="hidden"'
|
|
71
|
+
id = %(id="files-#{index}")
|
|
72
|
+
%(<table class="files" #{id} role="tabpanel"#{hidden}>)
|
|
73
|
+
end
|
|
27
74
|
|
|
28
75
|
# The table row of one Summary.files row; each cell has a
|
|
29
76
|
# data-sort value for the page's column sorting.
|
|
@@ -33,42 +80,50 @@ module PathCoverage
|
|
|
33
80
|
"<tr>#{cell(path, link)}#{counts(row).join}</tr>"
|
|
34
81
|
end
|
|
35
82
|
|
|
36
|
-
# The footer row: the sums over
|
|
83
|
+
# The footer row: the sums over the table's files.
|
|
37
84
|
def self.total_row(total)
|
|
38
85
|
"<tr>#{cell('Total', 'Total')}#{counts(total).join}</tr>"
|
|
39
86
|
end
|
|
40
87
|
|
|
41
88
|
# The cells after the file's: its sites, then its total
|
|
42
89
|
# within each decision, then, when methods are measured, its
|
|
43
|
-
# total
|
|
90
|
+
# total within each method.
|
|
44
91
|
def self.counts(row)
|
|
45
92
|
cells = [
|
|
46
93
|
cell(row[:sites], row[:sites]),
|
|
47
|
-
|
|
94
|
+
*paths_cells(row[:decisions], 'no decisions')
|
|
48
95
|
]
|
|
49
|
-
|
|
96
|
+
return cells unless row[:methods]
|
|
97
|
+
|
|
98
|
+
none = 'no method with two or more decisions'
|
|
99
|
+
cells + paths_cells(row[:methods], none)
|
|
50
100
|
end
|
|
51
101
|
|
|
52
|
-
#
|
|
53
|
-
# sorted by its
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
102
|
+
# Three cells showing [taken, possible] paths, eg 3, / 6 and
|
|
103
|
+
# 50%, each sorted by its number, so they read 3 / 6 50%.
|
|
104
|
+
# With no paths to take, as in a group no file is in, or a
|
|
105
|
+
# file with no method of two or more decisions, the percent
|
|
106
|
+
# shows -, titled with none, saying why, with an empty sort
|
|
107
|
+
# value, which sort.js keeps last whichever way the column
|
|
108
|
+
# is sorted.
|
|
109
|
+
def self.paths_cells(pair, none)
|
|
58
110
|
taken, possible = pair
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
111
|
+
counts = [cell(taken, taken, css: 'taken'),
|
|
112
|
+
cell(possible, "/ #{possible}", css: 'of')]
|
|
113
|
+
none_cell = cell('', '-', none, css: 'percent')
|
|
114
|
+
return [*counts, none_cell] if possible.zero?
|
|
62
115
|
|
|
63
116
|
percent = (100.0 * taken / possible).round
|
|
64
|
-
cell(percent, "#{
|
|
117
|
+
[*counts, cell(percent, "#{percent}%", css: 'percent')]
|
|
65
118
|
end
|
|
66
119
|
|
|
67
120
|
# One table cell, sorted by sort_value, showing content,
|
|
68
|
-
# with title as its hover text
|
|
69
|
-
|
|
121
|
+
# with title as its hover text and css as its class, each
|
|
122
|
+
# when there is one.
|
|
123
|
+
def self.cell(sort_value, content, title = nil, css: nil)
|
|
70
124
|
hover = title ? %( title="#{title}") : ''
|
|
71
|
-
%(
|
|
125
|
+
klass = css ? %( class="#{css}") : ''
|
|
126
|
+
%(<td#{klass} data-sort="#{sort_value}"#{hover}>#{content}</td>)
|
|
72
127
|
end
|
|
73
128
|
end
|
|
74
129
|
end
|