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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0b1aba7a743838c35d3b4d139b40efebafd48edef44b82ffc48f7c045eb6861
4
- data.tar.gz: '074496c6beaf83b9c6ec438019eb20e7b4f8231230ba94cd5e7a87cc2dc01eb1'
3
+ metadata.gz: a61c323a92442b231f61a10ea7ee622ee5e3b957f9818819e56aefda2ed75efd
4
+ data.tar.gz: eba0b9b94f2ba0756f0549d9675a4d3cb4c548df4854114df6e76e7ce09cdbeb
5
5
  SHA512:
6
- metadata.gz: debb313edef170467b8146405bc25f212b852798c19492b20699a111d255c05015ade7357a90cc9666e9a5642674fa31a0dcea3f930978bd2ef8bad2b37cab04
7
- data.tar.gz: e90aa8e71930f57e5e50b5f70155d7166ace8741f300abb7092bafbb3d47bafaaa3ce409cfe7102fc1795dfe7cea5224961a8497e027a617351b371010d83432
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.3 covers `&&`, `||`, `and` and `or`: each leaf operand of a
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
- Condition = Data.define(:operator, :operands, :commands) do
7
- # A condition with no command-call leaves by default.
8
- def initialize(operator:, operands:, commands: [])
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 72px; padding: 10px 16px; border: 1px solid var(--rule);
96
- border-radius: 8px; background: var(--panel); line-height: 30px;
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(--bad); }
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
- body = blocks.map { |block| block(block, trees) }.join("\n")
32
- %(<div class="code">\n#{body}\n</div>)
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
- def self.block(lines, trees)
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
- span_block(lines, site_trees.size) + tree(number, site_trees)
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 all their numbers,
47
- # and how many sites it opens when that is two or more,
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
- %(<div class="block">#{button(lines[0], content)}#{srcs}</div>)
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
- HEADINGS = %w[condition true false gap].freeze
65
-
66
- # The hidden tree of a span: a heading row, then each site's
67
- # rows in a group of their own.
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 closed button over a span's numbers, opening the tree
139
- # named after its first line.
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]}" aria-expanded="false"),
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
- %(<span class="lineno" data-line="#{number}">#{number}</span>)
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}">&#9650;</a>),
62
+ %(<a data-next="" href="#L#{following}"),
63
+ %( aria-label="Next gap, line #{following}">&#9660;</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
- @commands = []
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 = @commands
48
- @commands = []
47
+ outer = @notes
48
+ @notes = { commands: [], fixed: {} }
49
49
  condition = condition_of(node)
50
- @sites[site] = condition.with(commands: @commands)
51
- @commands = outer
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 commands
81
- # if it is a command call.
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
- size = condition.leaves.size
20
- [site, Array.new(size) { |i| counts_of(site, i) }]
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 every operand was seen both ways, else :gaps.
63
+ # :covered when no operand has a gap, else :gaps.
62
64
  def self.status_of(counts)
63
- counts.all? { |operand| seen_both?(operand) } ? :covered : :gaps
64
- end
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.size * 2
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:,
@@ -1,3 +1,3 @@
1
1
  module ConditionCoverage
2
- VERSION = '0.0.3'.freeze
2
+ VERSION = '0.0.5'.freeze
3
3
  end
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.3
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