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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +287 -78
  3. data/lib/path_coverage/calls.rb +54 -15
  4. data/lib/path_coverage/condition.rb +25 -2
  5. data/lib/path_coverage/configuration.rb +44 -7
  6. data/lib/path_coverage/configuration_paths.rb +56 -0
  7. data/lib/path_coverage/console_summary.rb +77 -0
  8. data/lib/path_coverage/gaps.rb +25 -9
  9. data/lib/path_coverage/html/index.html +3 -17
  10. data/lib/path_coverage/html/side.js +21 -2
  11. data/lib/path_coverage/html/sort.js +24 -21
  12. data/lib/path_coverage/html/style.css +28 -1
  13. data/lib/path_coverage/html/tabs.js +15 -0
  14. data/lib/path_coverage/html_index.rb +86 -31
  15. data/lib/path_coverage/html_index_head.rb +49 -0
  16. data/lib/path_coverage/html_report.rb +30 -13
  17. data/lib/path_coverage/html_tabs.rb +26 -0
  18. data/lib/path_coverage/html_tree.rb +41 -20
  19. data/lib/path_coverage/json_reader.rb +94 -0
  20. data/lib/path_coverage/json_writer.rb +41 -9
  21. data/lib/path_coverage/kernel.rb +42 -20
  22. data/lib/path_coverage/kinds/base.rb +71 -0
  23. data/lib/path_coverage/kinds/block.rb +136 -0
  24. data/lib/path_coverage/kinds/case.rb +89 -0
  25. data/lib/path_coverage/kinds/chain.rb +45 -0
  26. data/lib/path_coverage/kinds/choice.rb +68 -0
  27. data/lib/path_coverage/kinds/contains.rb +111 -0
  28. data/lib/path_coverage/kinds/safe.rb +151 -0
  29. data/lib/path_coverage/kinds/write.rb +54 -0
  30. data/lib/path_coverage/kinds.rb +37 -0
  31. data/lib/path_coverage/manifest.rb +58 -19
  32. data/lib/path_coverage/manifest_blocks.rb +49 -10
  33. data/lib/path_coverage/manifest_chains.rb +76 -0
  34. data/lib/path_coverage/manifest_choices.rb +30 -17
  35. data/lib/path_coverage/manifest_folding.rb +91 -0
  36. data/lib/path_coverage/manifest_ignoring.rb +35 -0
  37. data/lib/path_coverage/manifest_writes.rb +67 -0
  38. data/lib/path_coverage/merge.rb +19 -3
  39. data/lib/path_coverage/post.rb +8 -8
  40. data/lib/path_coverage/recorder.rb +9 -49
  41. data/lib/path_coverage/recorder_blocks.rb +68 -0
  42. data/lib/path_coverage/recorder_cases.rb +23 -0
  43. data/lib/path_coverage/recorder_chains.rb +40 -0
  44. data/lib/path_coverage/recorder_writes.rb +26 -0
  45. data/lib/path_coverage/render.rb +58 -46
  46. data/lib/path_coverage/result.rb +19 -0
  47. data/lib/path_coverage/rewriter.rb +58 -77
  48. data/lib/path_coverage/rewriter_nested.rb +62 -0
  49. data/lib/path_coverage/site_paths.rb +9 -58
  50. data/lib/path_coverage/site_tree.rb +12 -57
  51. data/lib/path_coverage/summary.rb +32 -4
  52. data/lib/path_coverage/totals.rb +1 -1
  53. data/lib/path_coverage/version.rb +1 -1
  54. 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
- # across each method's decisions.
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, as full
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.map { |f| "#{@root}/#{f}" }
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
@@ -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 paths, as Result gives
8
- # them, in manifest order; texts is { site => each leaf's
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
- steps = each[:path].zip(texts).filter_map do |outcome, text|
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
- <table id="files">
18
- <thead>
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 the files table by the clicked column's data-sort values;
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
- var table = document.getElementById('files');
7
- var body = table.tBodies[0];
8
- var state = { column: -1, ascending: true };
7
+ document.querySelectorAll('table.files').forEach(function (table) {
8
+ var body = table.tBodies[0];
9
+ var state = { column: -1, ascending: true };
9
10
 
10
- table.querySelectorAll('thead button').forEach(function (button) {
11
- button.addEventListener('click', function () {
12
- var column = Number(button.dataset.column);
13
- var numeric = button.dataset.type === 'number';
14
- state.ascending = state.column === column ? !state.ascending : true;
15
- state.column = column;
16
- var rows = Array.from(body.rows);
17
- rows.sort(function (a, b) {
18
- var x = a.cells[column].dataset.sort;
19
- var y = b.cells[column].dataset.sort;
20
- if (x === '' || y === '') { return (x === '') - (y === ''); }
21
- var order = numeric ? Number(x) - Number(y) : x.localeCompare(y);
22
- return state.ascending ? order : -order;
23
- });
24
- rows.forEach(function (row) { body.appendChild(row); });
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
- th.setAttribute('aria-sort', i === column ? sort : 'none');
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
- .rail .row { grid-template-columns: auto minmax(120px, 1fr); grid-auto-columns: 26px; column-gap: 8px; }
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: the files table, sortable by any
9
- # column, with one row per Summary.files row, and total, as
10
- # Summary.total gives it, as its footer.
11
- def self.page(rows, total)
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' => HtmlTemplate.asset('sort.js'),
16
- 'rows' => rows.map { |row| row(row) }.join("\n"),
17
- 'total' => total_row(total),
18
- 'across' => total[:methods] ? ACROSS : ''
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 heading of the across column, there only when methods
23
- # are measured.
24
- ACROSS = '<th class="num" aria-sort="none"><button ' \
25
- 'type="button" data-column="3" data-type="number">' \
26
- "Across a method's decisions</button></th>".freeze
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 every file.
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 across a method's decisions.
90
+ # total within each method.
44
91
  def self.counts(row)
45
92
  cells = [
46
93
  cell(row[:sites], row[:sites]),
47
- paths_cell(row[:decisions])
94
+ *paths_cells(row[:decisions], 'no decisions')
48
95
  ]
49
- row[:methods] ? cells + [paths_cell(row[:methods])] : cells
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
- # A cell showing [taken, possible] paths, eg 3 / 6 (50%),
53
- # sorted by its percent. With no paths to take, as in a file
54
- # with no method of two or more decisions, it shows -,
55
- # titled with why, with an empty sort value, which sort.js
56
- # keeps last whichever way the column is sorted.
57
- def self.paths_cell(pair)
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
- if possible.zero?
60
- return cell('', '-', 'no method with two or more decisions')
61
- end
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, "#{taken} / #{possible} (#{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 when there is one.
69
- def self.cell(sort_value, content, title = nil)
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
- %(<td data-sort="#{sort_value}"#{hover}>#{content}</td>)
125
+ klass = css ? %( class="#{css}") : ''
126
+ %(<td#{klass} data-sort="#{sort_value}"#{hover}>#{content}</td>)
72
127
  end
73
128
  end
74
129
  end