condition_coverage 0.0.3 → 0.0.5
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 +15 -2
- data/lib/condition_coverage/condition.rb +6 -4
- data/lib/condition_coverage/gaps.rb +3 -1
- data/lib/condition_coverage/html/file.html +3 -0
- data/lib/condition_coverage/html/style.css +49 -6
- data/lib/condition_coverage/html_file.rb +33 -89
- data/lib/condition_coverage/html_nav.rb +67 -0
- data/lib/condition_coverage/html_tree.rb +77 -0
- data/lib/condition_coverage/manifest.rb +32 -8
- data/lib/condition_coverage/result.rb +13 -3
- data/lib/condition_coverage/source_lines.rb +5 -7
- data/lib/condition_coverage/summary.rb +1 -1
- data/lib/condition_coverage/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a61c323a92442b231f61a10ea7ee622ee5e3b957f9818819e56aefda2ed75efd
|
|
4
|
+
data.tar.gz: eba0b9b94f2ba0756f0549d9675a4d3cb4c548df4854114df6e76e7ce09cdbeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f84973aba8aa9323ba03323f5fabe1b99feda591d0973a715e27f792a61971c50fb87b90680db5245451f16a90180dcae7272a8bdc33fd25998429d63860cd45
|
|
7
|
+
data.tar.gz: b992c871ed4f1bd658d828fec3da583199a4ddf3a44c26f0cb79b142f57f74e294a5f9c50e672780fb4f213986163f9c5b0a64e03903e462aebaf8c7a78e07ae
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Condition coverage for Ruby, to run alongside SimpleCov's line and branch
|
|
4
4
|
coverage.
|
|
5
5
|
|
|
6
|
-
Status: 0.0.
|
|
6
|
+
Status: 0.0.5 covers `&&`, `||`, `and` and `or`: each leaf operand of a
|
|
7
7
|
condition, however its operators are chained, nested or grouped. Decisions
|
|
8
8
|
hidden inside calls come next (see Roadmap).
|
|
9
9
|
|
|
@@ -75,6 +75,16 @@ A chain of one operator is flat; a change of operator, by precedence or by
|
|
|
75
75
|
parentheses, nests. Parentheses are not part of the tree, since they decide
|
|
76
76
|
nothing.
|
|
77
77
|
|
|
78
|
+
An operand whose form allows one outcome only needs only to be evaluated:
|
|
79
|
+
its other outcome is impossible, not a gap. `Foo.new`, literals (`'s'`,
|
|
80
|
+
`1`, `:k`, `[]`, `{}`, `true`), lambdas and constants (`LATEST`,
|
|
81
|
+
`Config::DEFAULT`, since one holding nil or false is rare) can only be
|
|
82
|
+
truthy; `nil` and `false` only falsy:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
@clock = options[:clock] || MonotonicClock.new # operand 1: never evaluated, or no gap
|
|
86
|
+
```
|
|
87
|
+
|
|
78
88
|
- `:html` writes `index.html` to `coverage_dir`: the covered files with
|
|
79
89
|
their sites, outcomes seen (with a percent) and outcomes missing, each
|
|
80
90
|
column sortable. Each file name opens `files/<path>.html`, the file's
|
|
@@ -83,7 +93,10 @@ nothing.
|
|
|
83
93
|
missing) that opens a tree of the condition: its operators, nested as
|
|
84
94
|
Ruby groups them, and each leaf operand's true and false counts and its
|
|
85
95
|
gap. A condition written over several lines has one button over all of
|
|
86
|
-
its line numbers.
|
|
96
|
+
its line numbers. Trees with gaps start open. A header that stays at
|
|
97
|
+
the top of the page lists every site as a link to its line, covered
|
|
98
|
+
sites first, then those with gaps, and arrows beside each site with
|
|
99
|
+
gaps jump to the previous and next one, wrapping round at either end.
|
|
87
100
|
|
|
88
101
|
## How it works
|
|
89
102
|
|
|
@@ -2,10 +2,12 @@ module ConditionCoverage
|
|
|
2
2
|
# One site's condition as the manifest found it: its operator
|
|
3
3
|
# and its operands, each the byte range of a leaf or a nested
|
|
4
4
|
# Condition. A site's commands are the byte ranges of its
|
|
5
|
-
# leaves that are command calls, eg `ok? id
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
# leaves that are command calls, eg `ok? id`; its fixed are
|
|
6
|
+
# { byte range => :truthy } for leaves that can have that one
|
|
7
|
+
# outcome only, eg `Clock.new`.
|
|
8
|
+
Condition = Data.define(:operator, :operands, :commands, :fixed) do
|
|
9
|
+
# A condition with no command-call or fixed leaves by default.
|
|
10
|
+
def initialize(operator:, operands:, commands: [], fixed: {})
|
|
9
11
|
super
|
|
10
12
|
end
|
|
11
13
|
|
|
@@ -25,9 +25,11 @@ module ConditionCoverage
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# The one outcome this operand is missing, if any
|
|
28
|
+
# The one outcome this operand is missing, if any; a fixed
|
|
29
|
+
# leaf, which can have one outcome only, needs only to run.
|
|
29
30
|
def self.operand_gap(counts)
|
|
30
31
|
return ['never evaluated'] if never_ran?(counts)
|
|
32
|
+
return [] if counts[:only]
|
|
31
33
|
return ['never true'] if counts[:truthy].zero?
|
|
32
34
|
return ['never false'] if counts[:falsy].zero?
|
|
33
35
|
|
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<main class="page">
|
|
15
|
+
<header class="head" data-head="">
|
|
15
16
|
<p class="back"><a data-index="" href="{{index}}">Condition coverage</a></p>
|
|
16
17
|
<h1 class="path">{{path}}</h1>
|
|
18
|
+
{{jumps}}
|
|
19
|
+
</header>
|
|
17
20
|
<div class="box">
|
|
18
21
|
{{listing}}
|
|
19
22
|
</div>
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
--bad: #B54708;
|
|
14
14
|
--on-status: #FFFFFF;
|
|
15
15
|
--link: #1F5FBF;
|
|
16
|
+
/* The tree is inverted from the page, so it never reads as
|
|
17
|
+
more source lines. */
|
|
18
|
+
--tree-bg: #1E2638;
|
|
19
|
+
--tree-fg: #E8ECF4;
|
|
20
|
+
--tree-muted: #A3AEC2;
|
|
21
|
+
--tree-rule: #3A4560;
|
|
22
|
+
--tree-gap: #F5A25D;
|
|
16
23
|
--font-body: "Helvetica Neue", Arial, sans-serif;
|
|
17
24
|
--font-code: Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
|
18
25
|
}
|
|
@@ -21,6 +28,8 @@
|
|
|
21
28
|
--bg: #12151C; --surface: #181C25; --panel: #212633; --fg: #E4E8EF;
|
|
22
29
|
--muted: #9AA4B5; --rule: #323949; --ok: #7FB0FF; --ok-tint: #1A2740;
|
|
23
30
|
--warn: #F0A35E; --bad: #F08A3C; --on-status: #12151C; --link: #7FB0FF;
|
|
31
|
+
--tree-bg: #E6EAF2; --tree-fg: #1C2230; --tree-muted: #4E5869;
|
|
32
|
+
--tree-rule: #BCC4D2; --tree-gap: #A8480A;
|
|
24
33
|
color-scheme: dark;
|
|
25
34
|
}
|
|
26
35
|
}
|
|
@@ -52,6 +61,39 @@ th button:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
|
|
|
52
61
|
digits stay aligned whether or not they are on a button. */
|
|
53
62
|
.back { margin: 0; font-size: 14px; }
|
|
54
63
|
h1.path { font-family: var(--font-code); font-size: 20px; }
|
|
64
|
+
/* The back link, path and site lists stick together as one
|
|
65
|
+
header. */
|
|
66
|
+
.head {
|
|
67
|
+
position: sticky; top: 0; z-index: 5;
|
|
68
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
69
|
+
margin: 0 -16px; padding: 12px 16px; background: var(--bg);
|
|
70
|
+
border-bottom: 1px solid var(--rule);
|
|
71
|
+
}
|
|
72
|
+
.jumps { display: flex; flex-direction: column; gap: 8px; }
|
|
73
|
+
.jump-list { display: flex; gap: 6px; align-items: center; overflow-x: auto; white-space: nowrap; min-width: 0; }
|
|
74
|
+
.jump-label { width: 64px; flex-shrink: 0; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
75
|
+
a.jump {
|
|
76
|
+
font: 600 13px/22px var(--font-code); padding: 0 9px; border-radius: 999px;
|
|
77
|
+
text-decoration: none; border: 1px solid; font-variant-numeric: tabular-nums;
|
|
78
|
+
}
|
|
79
|
+
a.jump[data-status="covered"] { background: var(--ok-tint); color: var(--ok); border-color: var(--ok); }
|
|
80
|
+
a.jump[data-status="gaps"] { background: var(--bad); color: var(--on-status); border-color: var(--bad); }
|
|
81
|
+
a.jump:focus-visible, .nav a:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
|
|
82
|
+
/* The source pane's border and background start after the nav
|
|
83
|
+
column, so the arrows sit outside the pane yet scroll with
|
|
84
|
+
their line. */
|
|
85
|
+
.box:has(> .code) { position: relative; isolation: isolate; border: 0; background: none; border-radius: 0; }
|
|
86
|
+
.box:has(> .code)::before {
|
|
87
|
+
content: ""; position: absolute; inset: 0 0 0 48px; z-index: -1;
|
|
88
|
+
border: 1px solid var(--rule); border-radius: 8px;
|
|
89
|
+
background: var(--surface); pointer-events: none;
|
|
90
|
+
}
|
|
91
|
+
.nav { display: flex; width: 48px; flex-shrink: 0; }
|
|
92
|
+
.nav a { width: 22px; text-align: center; line-height: 32px; font-size: 18px; color: var(--bad); text-decoration: none; }
|
|
93
|
+
.nav a:hover { color: var(--fg); }
|
|
94
|
+
/* Clears the sticky header when a link jumps to a line. */
|
|
95
|
+
.lineno { scroll-margin-top: 190px; }
|
|
96
|
+
button.lnum:has(.lineno:target) { outline: 3px solid var(--link); outline-offset: 2px; }
|
|
55
97
|
.code { font-family: var(--font-code); font-size: 14px; line-height: 32px; padding: 10px 0; min-width: 720px; }
|
|
56
98
|
.line, .block { display: flex; align-items: flex-start; }
|
|
57
99
|
.srcs { display: flex; flex-direction: column; }
|
|
@@ -84,7 +126,7 @@ button.lnum:has(.lineno + .lineno)::before { top: auto; bottom: 2px; line-height
|
|
|
84
126
|
/* The rule between sites spans the tree's full width, through
|
|
85
127
|
its 16px side padding. */
|
|
86
128
|
.site + .site {
|
|
87
|
-
border-top: 1px solid var(--rule); margin: 6px -16px 0; padding: 6px 16px 0;
|
|
129
|
+
border-top: 1px solid var(--tree-rule); margin: 6px -16px 0; padding: 6px 16px 0;
|
|
88
130
|
}
|
|
89
131
|
button.lnum .lineno { width: 50px; padding-right: 19px; color: inherit; line-height: 32px; margin: -4px 0; }
|
|
90
132
|
button.lnum[data-status="covered"] { background: var(--ok-tint); color: var(--ok); border-color: var(--ok); }
|
|
@@ -92,15 +134,16 @@ button.lnum[data-status="gaps"] { background: var(--bad); color: var(--on-status
|
|
|
92
134
|
button.lnum:hover { filter: brightness(1.1); }
|
|
93
135
|
button.lnum:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
|
|
94
136
|
.tree {
|
|
95
|
-
margin: 4px 20px 12px
|
|
96
|
-
border-radius: 8px; background: var(--
|
|
137
|
+
margin: 4px 20px 12px 120px; padding: 10px 16px; border: 1px solid var(--tree-rule);
|
|
138
|
+
border-radius: 8px; background: var(--tree-bg); color: var(--tree-fg); line-height: 30px;
|
|
139
|
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
|
97
140
|
}
|
|
98
141
|
.row { display: grid; grid-template-columns: auto minmax(200px, 1fr) 70px 70px 160px; column-gap: 16px; }
|
|
99
|
-
.row.headings { font-family: var(--font-body); font-size: 12px; color: var(--muted); }
|
|
142
|
+
.row.headings { font-family: var(--font-body); font-size: 12px; color: var(--tree-muted); }
|
|
100
143
|
.row.headings span:first-child { grid-column: span 2; }
|
|
101
144
|
.row.headings span:nth-child(2), .row.headings span:nth-child(3) { text-align: right; }
|
|
102
145
|
.operator { grid-column: span 2; font-weight: 600; }
|
|
103
|
-
.branch { white-space: pre; color: var(--muted); margin-right: -16px; }
|
|
146
|
+
.branch { white-space: pre; color: var(--tree-muted); margin-right: -16px; }
|
|
104
147
|
.row .text { white-space: pre; }
|
|
105
148
|
.row .truthy, .row .falsy { text-align: right; font-variant-numeric: tabular-nums; }
|
|
106
|
-
.row .gap { color: var(--
|
|
149
|
+
.row .gap { color: var(--tree-gap); font-weight: 600; }
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require 'cgi/escape'
|
|
2
|
+
require_relative 'html_nav'
|
|
2
3
|
require_relative 'html_template'
|
|
4
|
+
require_relative 'html_tree'
|
|
3
5
|
|
|
4
6
|
module ConditionCoverage
|
|
5
7
|
# One covered file's HTML page: its source, with the numbers
|
|
@@ -16,6 +18,7 @@ module ConditionCoverage
|
|
|
16
18
|
'index' => "#{'../' * (path.count('/') + 1)}index.html",
|
|
17
19
|
'style' => HtmlTemplate.asset('style.css'),
|
|
18
20
|
'script' => HtmlTemplate.asset('toggle.js'),
|
|
21
|
+
'jumps' => HtmlNav.jumps(lines),
|
|
19
22
|
'listing' => listing(lines, trees)
|
|
20
23
|
)
|
|
21
24
|
end
|
|
@@ -28,30 +31,36 @@ module ConditionCoverage
|
|
|
28
31
|
blocks = lines.chunk_while do |line, following|
|
|
29
32
|
line[:span] && line[:span] == following[:span]
|
|
30
33
|
end
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
gaps = HtmlNav.gap_lines(blocks)
|
|
35
|
+
body = blocks.map { |block| block(block, trees, gaps) }
|
|
36
|
+
%(<div class="code">\n#{body.join("\n")}\n</div>)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# A plain line, or the lines of one span behind one button
|
|
36
|
-
# with the span's tree straight after them
|
|
37
|
-
|
|
40
|
+
# with the span's tree straight after them; either way after
|
|
41
|
+
# its nav cell.
|
|
42
|
+
def self.block(lines, trees, gaps)
|
|
38
43
|
first = lines[0]
|
|
39
|
-
return plain_line(first) if first[:span].nil?
|
|
40
|
-
|
|
41
44
|
number = first[:number]
|
|
45
|
+
cell = HtmlNav.cell(number, gaps)
|
|
46
|
+
return plain_line(first, cell) if first[:span].nil?
|
|
47
|
+
|
|
42
48
|
site_trees = trees.fetch(number, [])
|
|
43
|
-
|
|
49
|
+
open = first[:status] == :gaps
|
|
50
|
+
span_block(lines, site_trees.size, cell) +
|
|
51
|
+
HtmlTree.of(number, site_trees, open)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
|
-
# The lines of one span: one button over
|
|
47
|
-
# and how many sites it opens when that is
|
|
48
|
-
# beside their code.
|
|
49
|
-
def self.span_block(lines, sites)
|
|
54
|
+
# The lines of one span after their nav cell: one button over
|
|
55
|
+
# all their numbers, and how many sites it opens when that is
|
|
56
|
+
# two or more, beside their code.
|
|
57
|
+
def self.span_block(lines, sites, cell)
|
|
50
58
|
numbers = lines.map { |line| number(line[:number]) }.join
|
|
51
59
|
codes = lines.map { |line| code(line) }.join
|
|
52
60
|
srcs = %(<div class="srcs">#{codes}</div>)
|
|
53
61
|
content = site_count(sites) + numbers
|
|
54
|
-
|
|
62
|
+
button = button(lines[0], content)
|
|
63
|
+
%(<div class="block">#{cell}#{button}#{srcs}</div>)
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
# The count of a button's sites, shown only for two or more.
|
|
@@ -61,93 +70,28 @@ module ConditionCoverage
|
|
|
61
70
|
%(<span class="site-count" data-site-count="">#{sites}</span>)
|
|
62
71
|
end
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def self.tree(number, site_trees)
|
|
69
|
-
groups = site_trees.map do |site|
|
|
70
|
-
rows = site_rows(site).join
|
|
71
|
-
%(<div class="site" data-site="">#{rows}</div>)
|
|
72
|
-
end
|
|
73
|
-
id = "tree-#{number}"
|
|
74
|
-
body = [heading_row, *groups].join
|
|
75
|
-
%(<div class="tree" id="#{id}" hidden="hidden">#{body}</div>)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# The row naming each column of a tree.
|
|
79
|
-
def self.heading_row
|
|
80
|
-
cells = HEADINGS.map { |name| %(<span>#{name}</span>) }.join
|
|
81
|
-
%(<div class="row headings" data-heading="">#{cells}</div>)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# A site's operator row, then the rows of its operands.
|
|
85
|
-
def self.site_rows(site)
|
|
86
|
-
row = %(<div class="row">#{operator(site)}</div>)
|
|
87
|
-
[row, *operand_rows(site, '')]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# The rows of a condition's operands, each branching from
|
|
91
|
-
# its operator after indent; a nested condition's operands
|
|
92
|
-
# follow its own row, indented under its branch.
|
|
93
|
-
def self.operand_rows(condition, indent)
|
|
94
|
-
last = condition[:operands].size - 1
|
|
95
|
-
condition[:operands].each_with_index.flat_map do |operand, i|
|
|
96
|
-
tip = i == last
|
|
97
|
-
branch = indent + (tip ? '└─ ' : '├─ ')
|
|
98
|
-
next [operand_row(operand, branch)] unless operand[:operator]
|
|
99
|
-
|
|
100
|
-
nested = operand_rows(operand, indent + (tip ? ' ' : '│ '))
|
|
101
|
-
[subtree_row(operand, branch), *nested]
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# A nested condition's row: its branch and its operator.
|
|
106
|
-
def self.subtree_row(condition, branch)
|
|
107
|
-
cells = branch_cell(branch) + operator(condition)
|
|
108
|
-
%(<div class="row" data-subtree="">#{cells}</div>)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# The cell naming a condition's operator.
|
|
112
|
-
def self.operator(condition)
|
|
113
|
-
name = CGI.escapeHTML(condition[:operator])
|
|
114
|
-
%(<span class="operator" data-operator="">#{name}</span>)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# One operand's row: its branch, text, true and false
|
|
118
|
-
# counts, and gap.
|
|
119
|
-
def self.operand_row(operand, branch)
|
|
120
|
-
cells = %i[text truthy falsy gap].map do |name|
|
|
121
|
-
value = CGI.escapeHTML(operand[name].to_s)
|
|
122
|
-
%(<span class="#{name}" data-#{name}="">#{value}</span>)
|
|
123
|
-
end
|
|
124
|
-
lead = branch_cell(branch)
|
|
125
|
-
%(<div class="row" data-operand="">#{lead}#{cells.join}</div>)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# The cell drawing a row's branch from its operator.
|
|
129
|
-
def self.branch_cell(branch)
|
|
130
|
-
%(<span class="branch" data-branch="">#{branch}</span>)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# A line with no condition: its number, then its code.
|
|
134
|
-
def self.plain_line(line)
|
|
135
|
-
%(<div class="line">#{number(line[:number])}#{code(line)}</div>)
|
|
73
|
+
# A line with no condition: its nav cell, number and code.
|
|
74
|
+
def self.plain_line(line, cell)
|
|
75
|
+
parts = cell + number(line[:number]) + code(line)
|
|
76
|
+
%(<div class="line">#{parts}</div>)
|
|
136
77
|
end
|
|
137
78
|
|
|
138
|
-
# The
|
|
139
|
-
#
|
|
79
|
+
# The button over a span's numbers, toggling the tree named
|
|
80
|
+
# after its first line; expanded when that tree starts open.
|
|
140
81
|
def self.button(first, numbers)
|
|
82
|
+
expanded = first[:status] == :gaps
|
|
141
83
|
[
|
|
142
84
|
%(<button type="button" class="lnum"),
|
|
143
|
-
%( data-status="#{first[:status]}"
|
|
85
|
+
%( data-status="#{first[:status]}"),
|
|
86
|
+
%( aria-expanded="#{expanded}"),
|
|
144
87
|
%( aria-controls="tree-#{first[:number]}">#{numbers}</button>)
|
|
145
88
|
].join
|
|
146
89
|
end
|
|
147
90
|
|
|
148
|
-
# One line's number
|
|
91
|
+
# One line's number, the target of a link to #L<number>.
|
|
149
92
|
def self.number(number)
|
|
150
|
-
%(
|
|
93
|
+
attributes = %(id="L#{number}" data-line="#{number}")
|
|
94
|
+
%(<span class="lineno" #{attributes}>#{number}</span>)
|
|
151
95
|
end
|
|
152
96
|
|
|
153
97
|
# One line's code, escaped.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module ConditionCoverage
|
|
2
|
+
# The ways around a file's page: the header's lists of links to
|
|
3
|
+
# each span, and the arrows between the spans with gaps.
|
|
4
|
+
module HtmlNav
|
|
5
|
+
JUMP_LISTS = { 'covered' => 'Covered', 'gaps' => 'Gaps' }.freeze
|
|
6
|
+
|
|
7
|
+
# The header's lists of links to the first line of each span:
|
|
8
|
+
# those covered, then those with gaps.
|
|
9
|
+
def self.jumps(lines)
|
|
10
|
+
firsts = lines.select { |line| first_of_span?(line) }
|
|
11
|
+
lists = JUMP_LISTS.map do |status, label|
|
|
12
|
+
spans = firsts.select { |line| line[:status].to_s == status }
|
|
13
|
+
jump_list(status, label, spans.map { |line| line[:number] })
|
|
14
|
+
end
|
|
15
|
+
%(<nav class="jumps" aria-label="Sites">#{lists.join}</nav>)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# True for the line a span starts on.
|
|
19
|
+
def self.first_of_span?(line)
|
|
20
|
+
line[:span]&.begin == line[:number]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One labelled list of links to lines.
|
|
24
|
+
def self.jump_list(status, label, numbers)
|
|
25
|
+
links = numbers.map { |number| jump(status, number) }
|
|
26
|
+
name = %(<span class="jump-label">#{label}</span>)
|
|
27
|
+
list = %(<div class="jump-list" data-jumps="#{status}">)
|
|
28
|
+
"#{list}#{name}#{links.join}</div>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One link to a line.
|
|
32
|
+
def self.jump(status, number)
|
|
33
|
+
attributes = %(data-status="#{status}" href="#L#{number}")
|
|
34
|
+
%(<a class="jump" #{attributes}>#{number}</a>)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The first line of each span whose sites have gaps, given the
|
|
38
|
+
# listing's blocks.
|
|
39
|
+
def self.gap_lines(blocks)
|
|
40
|
+
firsts = blocks.map(&:first)
|
|
41
|
+
firsts.select { |line| line[:status] == :gaps }.map do |line|
|
|
42
|
+
line[:number]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The cell left of a row's number: at a span with gaps, links
|
|
47
|
+
# to the previous and next such span, wrapping round at the
|
|
48
|
+
# ends; empty on every other row, so the numbers stay aligned.
|
|
49
|
+
def self.cell(number, gaps)
|
|
50
|
+
index = gaps.index(number)
|
|
51
|
+
links = index ? arrows(gaps, index) : ''
|
|
52
|
+
%(<span class="nav" data-nav="">#{links}</span>)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The links to the spans with gaps either side of gaps[index].
|
|
56
|
+
def self.arrows(gaps, index)
|
|
57
|
+
previous = gaps[index - 1]
|
|
58
|
+
following = gaps[(index + 1) % gaps.size]
|
|
59
|
+
[
|
|
60
|
+
%(<a data-prev="" href="#L#{previous}"),
|
|
61
|
+
%( aria-label="Previous gap, line #{previous}">▲</a>),
|
|
62
|
+
%(<a data-next="" href="#L#{following}"),
|
|
63
|
+
%( aria-label="Next gap, line #{following}">▼</a>)
|
|
64
|
+
].join
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
|
|
3
|
+
module ConditionCoverage
|
|
4
|
+
# The tree of a span's sites on a file's page, from SiteTree.of
|
|
5
|
+
# for each site.
|
|
6
|
+
module HtmlTree
|
|
7
|
+
HEADINGS = %w[condition true false gap].freeze
|
|
8
|
+
|
|
9
|
+
# The tree of a span, open when its sites have gaps, else
|
|
10
|
+
# hidden: a heading row, then each site's rows in a group of
|
|
11
|
+
# their own.
|
|
12
|
+
def self.of(number, site_trees, open)
|
|
13
|
+
groups = site_trees.map do |site|
|
|
14
|
+
rows = site_rows(site).join
|
|
15
|
+
%(<div class="site" data-site="">#{rows}</div>)
|
|
16
|
+
end
|
|
17
|
+
hidden = open ? '' : ' hidden="hidden"'
|
|
18
|
+
body = [heading_row, *groups].join
|
|
19
|
+
%(<div class="tree" id="tree-#{number}"#{hidden}>#{body}</div>)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The row naming each column of a tree.
|
|
23
|
+
def self.heading_row
|
|
24
|
+
cells = HEADINGS.map { |name| %(<span>#{name}</span>) }.join
|
|
25
|
+
%(<div class="row headings" data-heading="">#{cells}</div>)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A site's operator row, then the rows of its operands.
|
|
29
|
+
def self.site_rows(site)
|
|
30
|
+
row = %(<div class="row">#{operator(site)}</div>)
|
|
31
|
+
[row, *operand_rows(site, '')]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The rows of a condition's operands, each branching from
|
|
35
|
+
# its operator after indent; a nested condition's operands
|
|
36
|
+
# follow its own row, indented under its branch.
|
|
37
|
+
def self.operand_rows(condition, indent)
|
|
38
|
+
last = condition[:operands].size - 1
|
|
39
|
+
condition[:operands].each_with_index.flat_map do |operand, i|
|
|
40
|
+
tip = i == last
|
|
41
|
+
branch = indent + (tip ? '└─ ' : '├─ ')
|
|
42
|
+
next [operand_row(operand, branch)] unless operand[:operator]
|
|
43
|
+
|
|
44
|
+
nested = operand_rows(operand, indent + (tip ? ' ' : '│ '))
|
|
45
|
+
[subtree_row(operand, branch), *nested]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A nested condition's row: its branch and its operator.
|
|
50
|
+
def self.subtree_row(condition, branch)
|
|
51
|
+
cells = branch_cell(branch) + operator(condition)
|
|
52
|
+
%(<div class="row" data-subtree="">#{cells}</div>)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The cell naming a condition's operator.
|
|
56
|
+
def self.operator(condition)
|
|
57
|
+
name = CGI.escapeHTML(condition[:operator])
|
|
58
|
+
%(<span class="operator" data-operator="">#{name}</span>)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One operand's row: its branch, text, true and false
|
|
62
|
+
# counts, and gap.
|
|
63
|
+
def self.operand_row(operand, branch)
|
|
64
|
+
cells = %i[text truthy falsy gap].map do |name|
|
|
65
|
+
value = CGI.escapeHTML(operand[name].to_s)
|
|
66
|
+
%(<span class="#{name}" data-#{name}="">#{value}</span>)
|
|
67
|
+
end
|
|
68
|
+
lead = branch_cell(branch)
|
|
69
|
+
%(<div class="row" data-operand="">#{lead}#{cells.join}</div>)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The cell drawing a row's branch from its operator.
|
|
73
|
+
def self.branch_cell(branch)
|
|
74
|
+
%(<span class="branch" data-branch="">#{branch}</span>)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -19,7 +19,7 @@ module ConditionCoverage
|
|
|
19
19
|
def initialize(path)
|
|
20
20
|
@path = path
|
|
21
21
|
@sites = {}
|
|
22
|
-
@
|
|
22
|
+
@notes = nil
|
|
23
23
|
super()
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -44,11 +44,11 @@ module ConditionCoverage
|
|
|
44
44
|
def add_site(node)
|
|
45
45
|
site = Site.of(@path, node)
|
|
46
46
|
@sites[site] = nil
|
|
47
|
-
outer = @
|
|
48
|
-
@
|
|
47
|
+
outer = @notes
|
|
48
|
+
@notes = { commands: [], fixed: {} }
|
|
49
49
|
condition = condition_of(node)
|
|
50
|
-
@sites[site] = condition.with(
|
|
51
|
-
@
|
|
50
|
+
@sites[site] = condition.with(**@notes)
|
|
51
|
+
@notes = outer
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# node as a Condition, operands and all, named by its
|
|
@@ -77,16 +77,40 @@ module ConditionCoverage
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# The byte range of a leaf operand, once any site inside
|
|
80
|
-
# it is noted too, and the range among its site's
|
|
81
|
-
#
|
|
80
|
+
# it is noted too, and the range noted among its site's
|
|
81
|
+
# commands and fixed leaves where it is one.
|
|
82
82
|
def leaf(operand)
|
|
83
83
|
operand.accept(self)
|
|
84
84
|
loc = operand.location
|
|
85
85
|
range = loc.start_offset...loc.end_offset
|
|
86
|
-
@commands << range if command?(operand)
|
|
86
|
+
@notes[:commands] << range if command?(operand)
|
|
87
|
+
outcome = fixed_outcome(operand)
|
|
88
|
+
@notes[:fixed][range] = outcome if outcome
|
|
87
89
|
range
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
# A constant is taken to be truthy, as one holding nil or
|
|
93
|
+
# false is rare.
|
|
94
|
+
TRUTHY_TYPES = %i[
|
|
95
|
+
string_node integer_node symbol_node array_node hash_node
|
|
96
|
+
true_node lambda_node constant_read_node constant_path_node
|
|
97
|
+
].freeze
|
|
98
|
+
FALSY_TYPES = %i[nil_node false_node].freeze
|
|
99
|
+
|
|
100
|
+
# The one outcome a leaf can have, eg :truthy for `Clock.new`
|
|
101
|
+
# or a literal, :falsy for `nil`; nil for a leaf that can
|
|
102
|
+
# have either.
|
|
103
|
+
def fixed_outcome(node)
|
|
104
|
+
return :falsy if FALSY_TYPES.include?(node.type)
|
|
105
|
+
|
|
106
|
+
:truthy if TRUTHY_TYPES.include?(node.type) || new_call?(node)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True for a call to new, eg `Clock.new`.
|
|
110
|
+
def new_call?(node)
|
|
111
|
+
node.type == :call_node && node.name == :new
|
|
112
|
+
end
|
|
113
|
+
|
|
90
114
|
# True for a call to a named method with arguments and no
|
|
91
115
|
# parentheses, eg `id.include? 'N'`, but not `id.size == 6`.
|
|
92
116
|
def command?(node)
|
|
@@ -13,16 +13,26 @@ module ConditionCoverage
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# { site => [counts of operand 0, counts of operand 1, ...] },
|
|
16
|
-
# with zero counts for an operand that never ran
|
|
16
|
+
# with zero counts for an operand that never ran, and
|
|
17
|
+
# only: naming the one outcome a fixed leaf can have.
|
|
17
18
|
def operands
|
|
18
19
|
@sites.to_h do |site, condition|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
leaves = condition.leaves.each_with_index.map do |range, i|
|
|
21
|
+
leaf(site, condition, range, i)
|
|
22
|
+
end
|
|
23
|
+
[site, leaves]
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
private
|
|
25
28
|
|
|
29
|
+
# The counts of one leaf, with only: when it is fixed.
|
|
30
|
+
def leaf(site, condition, range, index)
|
|
31
|
+
counts = counts_of(site, index)
|
|
32
|
+
only = condition.fixed[range]
|
|
33
|
+
only ? counts.merge(only:) : counts
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
# The recorded counts of one operand, or zeros.
|
|
27
37
|
def counts_of(site, index)
|
|
28
38
|
@counts.fetch([site, index], NEVER_RAN)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'gaps'
|
|
2
|
+
|
|
1
3
|
module ConditionCoverage
|
|
2
4
|
# The lines of one file's page, each with the status of the
|
|
3
5
|
# conditions on it.
|
|
@@ -58,14 +60,10 @@ module ConditionCoverage
|
|
|
58
60
|
[one, other].include?(:gaps) ? :gaps : :covered
|
|
59
61
|
end
|
|
60
62
|
|
|
61
|
-
# :covered when
|
|
63
|
+
# :covered when no operand has a gap, else :gaps.
|
|
62
64
|
def self.status_of(counts)
|
|
63
|
-
counts.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
# True when the operand was seen both true and false.
|
|
67
|
-
def self.seen_both?(counts)
|
|
68
|
-
counts[:truthy].positive? && counts[:falsy].positive?
|
|
65
|
+
gaps = counts.flat_map { |operand| Gaps.operand_gap(operand) }
|
|
66
|
+
gaps.empty? ? :covered : :gaps
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
# The line a site starts on, from its path:line:column name.
|
|
@@ -10,7 +10,7 @@ module ConditionCoverage
|
|
|
10
10
|
# The summary row of one file's sites.
|
|
11
11
|
def self.row(path, sites)
|
|
12
12
|
operands = sites.flat_map { |_, counts| counts }
|
|
13
|
-
outcomes = operands.
|
|
13
|
+
outcomes = operands.sum { |counts| counts[:only] ? 1 : 2 }
|
|
14
14
|
seen = operands.sum { |counts| seen_in(counts) }
|
|
15
15
|
{
|
|
16
16
|
path:, sites: sites.size, seen:, outcomes:,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: condition_coverage
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon Jagger
|
|
@@ -42,8 +42,10 @@ files:
|
|
|
42
42
|
- lib/condition_coverage/html/toggle.js
|
|
43
43
|
- lib/condition_coverage/html_file.rb
|
|
44
44
|
- lib/condition_coverage/html_index.rb
|
|
45
|
+
- lib/condition_coverage/html_nav.rb
|
|
45
46
|
- lib/condition_coverage/html_report.rb
|
|
46
47
|
- lib/condition_coverage/html_template.rb
|
|
48
|
+
- lib/condition_coverage/html_tree.rb
|
|
47
49
|
- lib/condition_coverage/json_writer.rb
|
|
48
50
|
- lib/condition_coverage/kernel.rb
|
|
49
51
|
- lib/condition_coverage/manifest.rb
|