condition_coverage 0.0.1 → 0.0.3

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: 8fbd7cd27ab04963d5c91539a6accc52d6f178eef85858b48e6b553dd51f39de
4
- data.tar.gz: 59cebe77670d20308973d24f96712368a204adfb32ac90a7e0497f78aabf9331
3
+ metadata.gz: e0b1aba7a743838c35d3b4d139b40efebafd48edef44b82ffc48f7c045eb6861
4
+ data.tar.gz: '074496c6beaf83b9c6ec438019eb20e7b4f8231230ba94cd5e7a87cc2dc01eb1'
5
5
  SHA512:
6
- metadata.gz: ff8f96409d90bb54be22e3755e461849c2f9dfaf5a0ca0fbf5bb685b64c2066a22385e5cf7bfec2bcebd12ddf8687555d56112e107f5c242a33e7a5156977ce5
7
- data.tar.gz: 86324f587f50156ff2ae9049f7d019fd1eb17b984f4ac0b45a2e0ecbccd114b1d849d9e8cdde4dd824d7e5e047e153f7e9d622474f811e9bcaeb471b38159179
6
+ metadata.gz: debb313edef170467b8146405bc25f212b852798c19492b20699a111d255c05015ade7357a90cc9666e9a5642674fa31a0dcea3f930978bd2ef8bad2b37cab04
7
+ data.tar.gz: e90aa8e71930f57e5e50b5f70155d7166ace8741f300abb7092bafbb3d47bafaaa3ce409cfe7102fc1795dfe7cea5224961a8497e027a617351b371010d83432
data/README.md CHANGED
@@ -1,75 +1,152 @@
1
1
  # condition_coverage
2
2
 
3
- Status: an outline of the idea. No working code is published yet.
4
-
5
3
  Condition coverage for Ruby, to run alongside SimpleCov's line and branch
6
4
  coverage.
7
5
 
6
+ Status: 0.0.3 covers `&&`, `||`, `and` and `or`: each leaf operand of a
7
+ condition, however its operators are chained, nested or grouped. Decisions
8
+ hidden inside calls come next (see Roadmap).
9
+
10
+ Demo: https://cyber-dojo.github.io/condition_coverage/ shows the HTML
11
+ report for the mock source tree in `demo/`. `make demo` builds and opens
12
+ the same report locally.
13
+
8
14
  ## The gap it fills
9
15
 
10
16
  Ruby's built-in `Coverage` (which SimpleCov reads) tracks branches, not the
11
17
  conditions inside them:
12
18
 
13
19
  ```ruby
14
- if a || b || c
20
+ return :ok if ok?(id) && id.start_with?('N')
15
21
  ```
16
22
 
17
- is two branches. Tests that reach both can still leave `b` never true and `c`
18
- never evaluated, and branch coverage reports 100%.
19
-
20
- ## How it works
23
+ is two branches. Tests that reach both can still leave `id.start_with?('N')`
24
+ never false, or never evaluated, and branch coverage reports 100%.
21
25
 
22
- At load time, each file under the covered root is parsed with Prism and each
23
- `&&` / `||` operand is wrapped in a recorder call, before compilation:
26
+ ## Use
24
27
 
25
28
  ```ruby
26
- if __cc(7, 0, a) || __cc(7, 1, b) || __cc(7, 2, c)
29
+ # test coverage config, before any application code is required
30
+ require 'simplecov'
31
+ require 'condition_coverage'
32
+ SimpleCov.start do
33
+ # ...
34
+ end
35
+ ConditionCoverage.start do
36
+ root APP_DIR # cover globs and site names are relative to it
37
+ cover 'source/**/*.rb' # each matching file is instrumented as it loads
38
+ coverage_dir '/tmp/coverage' # where condition_coverage.json is written
39
+ report :gaps, :html # the reporters run at exit (default :gaps)
40
+ end
27
41
  ```
28
42
 
29
- The recorder notes whether the operand was truthy or falsy and returns it
30
- unchanged, so short-circuiting and the value of the expression are preserved.
31
- The rewrite happens in memory only; no file on disk changes.
43
+ A misspelt setting or an unknown reporter is refused by name, with the list
44
+ of the ones there are. Calling `start` again replaces the configuration.
45
+
46
+ ## What a run reports
47
+
48
+ As the process exits:
49
+
50
+ - `condition_coverage.json` is always written to `coverage_dir`: every site
51
+ of every covered file, with each operand's true and false counts. It is
52
+ the base the other reporters read.
53
+ - `:gaps` prints one line per missing outcome, in manifest order:
32
54
 
33
- A static manifest of every site (file, line, column, operand count) is built
34
- by scanning the covered files, so an operand that never ran, or a file never
35
- loaded, is reported as missed rather than silently absent.
55
+ ```text
56
+ source/kata.rb:3:23 operand 1 never false
57
+ source/kata.rb:4:24 never reached
58
+ ```
59
+
60
+ A site is named by the `path:line:column` where its condition starts, the
61
+ path relative to `root`. A site in a covered file the run never loaded is
62
+ still reported, as `never reached`.
36
63
 
37
- ## Beyond and/or
64
+ A site is an outermost condition. Its operands are numbered left to right
65
+ across the whole of it, nested conditions included:
38
66
 
39
- The same hook can record decisions hidden inside calls, each with its own
40
- outcome classes:
67
+ ```ruby
68
+ ok?(id) && new?(id) && id.size == 6 # one && site, operands 0 1 2
69
+ ok?(id) && (new?(id) || id.size == 6) # an || nested in an && site, 0 1 2
70
+ ok?(id) && new?(id) || id.size == 6 # an && nested in an || site, 0 1 2
71
+ ids.any? { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
72
+ ```
41
73
 
42
- - `any?` / `all?` / `none?`: empty, decided by the first item, decided later,
43
- never decided
44
- - `find` / `detect`: found vs nil
45
- - `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
46
- - `x || default` in value context, `&.` safe navigation: nil vs not nil
47
- - ternaries, `unless`, modifier `if`, `case`/`when` and `in` patterns
48
- - regex matches: nil vs MatchData
74
+ A chain of one operator is flat; a change of operator, by precedence or by
75
+ parentheses, nests. Parentheses are not part of the tree, since they decide
76
+ nothing.
49
77
 
50
- ## Reporting
78
+ - `:html` writes `index.html` to `coverage_dir`: the covered files with
79
+ their sites, outcomes seen (with a percent) and outcomes missing, each
80
+ column sortable. Each file name opens `files/<path>.html`, the file's
81
+ source. On a line holding a condition the line number is a button (blue
82
+ and outlined when every outcome was seen, solid orange when any is
83
+ missing) that opens a tree of the condition: its operators, nested as
84
+ Ruby groups them, and each leaf operand's true and false counts and its
85
+ gap. A condition written over several lines has one button over all of
86
+ its line numbers.
51
87
 
52
- Gaps only, in the style of a Missing column. Each site shows its source line
53
- with a caret under each operand that was never true, never false, or never
54
- evaluated, plus a JSON form of the same data. An HTML view underlines each
55
- operand in its own colour.
88
+ ## How it works
56
89
 
57
- ## Intended use
90
+ At load time each covered file is read and parsed once, with Prism. The
91
+ parse gives the manifest: each site's condition tree and the byte range of
92
+ each of its leaf operands. The rewriter then inserts a record call around
93
+ each leaf, without parsing again, and the result is compiled in place of
94
+ the file:
58
95
 
59
96
  ```ruby
60
- # test coverage config, before any application code is required
61
- require 'simplecov'
62
- require 'condition_coverage'
63
- SimpleCov.start do
64
- # ...
65
- end
66
- ConditionCoverage.start(root: APP_DIR, cover: 'source/**/*.rb')
97
+ ok?(id) && new?(id)
98
+ # compiles as
99
+ ConditionCoverage.record("k.rb:2:2", 0, ok?(id)) && ConditionCoverage.record("k.rb:2:2", 1, new?(id))
67
100
  ```
68
101
 
69
- The figure is gathered and reported, not gated.
102
+ A command call such as `ok? id` (after `and`) is not valid as a bare
103
+ argument, so that operand alone gets parentheses of its own. No other operand
104
+ does: parentheses make a statement, and a statement on a continuation line
105
+ gives that line a line-coverage count of its own, which would change
106
+ SimpleCov's line totals. With that one exception, an instrumented file has
107
+ the same relevant lines as an uninstrumented one.
108
+
109
+ `record` counts whether the operand was truthy or falsy and returns it
110
+ unchanged, so short-circuiting and the value of the expression are
111
+ preserved. Only text is inserted, never a newline, so backtrace line numbers
112
+ still match the file. The rewrite happens in memory; no file on disk
113
+ changes. A condition with a single operand is left alone, since branch
114
+ coverage already sees both its outcomes.
115
+
116
+ At exit the manifests parsed during the run are reused; only covered files
117
+ the run never loaded are read and parsed then.
118
+
119
+ ## Roadmap
120
+
121
+ - decisions hidden inside calls, each with its own outcome classes:
122
+ - `any?` / `all?` / `none?`: empty, decided by the first item, decided
123
+ later, never decided
124
+ - `find` / `detect`: found vs nil
125
+ - `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
126
+ - `x || default` in value context, `&.` safe navigation: nil vs not nil
127
+ - ternaries, `unless`, modifier `if`, `case`/`when` and `in` patterns
128
+ - regex matches: nil vs MatchData
70
129
 
71
130
  ## Constraints
72
131
 
73
132
  - CRuby only: the hook is `RubyVM::InstructionSequence.load_iseq`.
133
+ - Ruby 3.2 or later, for `String#bytesplice`.
74
134
  - That hook has a single slot, which Bootsnap also uses, so the two cannot
75
135
  both be active in one process.
136
+
137
+ ## Development
138
+
139
+ `make all` runs, in a Docker image built from the repo's `Dockerfile`:
140
+
141
+ - `make lint`: rubocop over `lib/`, `test/` and `bin/` alike
142
+ - `make test`: the tests, then a check that the coverage report has zero
143
+ missed lines and branches in both the code and the tests, within the
144
+ bounds in `test/coverage_metrics_params.json`
145
+ (`make test tids='6D1K'` runs only matching tests, and skips the check)
146
+ - `make test_check`: shunit2 tests showing that a breached bound fails the
147
+ check with a non-zero exit
148
+
149
+ `make demo` (not part of `make all`) runs `demo/run.rb` through
150
+ `bin/build_demo.sh`, writing the report to `site/` and opening it. The
151
+ Pages workflow (`.github/workflows/pages.yml`) runs the same script on
152
+ each push to `main` and publishes `site/`.
@@ -0,0 +1,20 @@
1
+ module ConditionCoverage
2
+ # One site's condition as the manifest found it: its operator
3
+ # and its operands, each the byte range of a leaf or a nested
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: [])
9
+ super
10
+ end
11
+
12
+ # The byte range of every leaf operand, left to right; a
13
+ # leaf's index here is the index it is recorded under.
14
+ def leaves
15
+ operands.flat_map do |operand|
16
+ operand.is_a?(Condition) ? operand.leaves : [operand]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,98 @@
1
+ module ConditionCoverage
2
+ # Raised for a start block that cannot be applied.
3
+ class ConfigurationError < StandardError; end
4
+
5
+ # The settings given in a ConditionCoverage.start block.
6
+ class Configuration
7
+ GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
8
+ SETTINGS = %w[cover coverage_dir report root].freeze
9
+ REPORTERS = %w[gaps html].freeze
10
+
11
+ # Evaluates the settings block against a new configuration.
12
+ def initialize(&)
13
+ @globs = []
14
+ @reporters = [:gaps]
15
+ instance_eval(&)
16
+ end
17
+
18
+ # The reporters chosen with report, :gaps when none were.
19
+ attr_reader :reporters
20
+
21
+ # The dir the reports are written to.
22
+ def output_dir
23
+ @coverage_dir
24
+ end
25
+
26
+ # Sets the dir that cover globs and site names are
27
+ # relative to.
28
+ def root(dir)
29
+ @root = dir
30
+ end
31
+
32
+ # Adds a glob, relative to root, of files to instrument.
33
+ def cover(glob)
34
+ @globs << glob
35
+ end
36
+
37
+ # Chooses the reporters run at exit, eg report :gaps;
38
+ # refuses a name there is no reporter for.
39
+ def report(*names)
40
+ names.each do |name|
41
+ next if REPORTERS.include?(name.to_s)
42
+
43
+ raise ConfigurationError, [
44
+ "unknown reporter #{name} in ConditionCoverage.start",
45
+ "the reporters are: #{REPORTERS.join(', ')}"
46
+ ].join("\n")
47
+ end
48
+ @reporters = names.map(&:to_sym)
49
+ end
50
+
51
+ # Sets the dir the condition_coverage.json report is
52
+ # written to.
53
+ def coverage_dir(dir)
54
+ @coverage_dir = dir
55
+ end
56
+
57
+ # Every file the cover globs match under root, as full
58
+ # paths, whether or not the run loads it.
59
+ def files
60
+ Dir.glob(@globs, base: @root).sort.map { |f| "#{@root}/#{f}" }
61
+ end
62
+
63
+ # Where the JSON report goes.
64
+ def json_path
65
+ "#{output_dir}/condition_coverage.json"
66
+ end
67
+
68
+ # True when path is under root and matches a cover glob.
69
+ def covers?(path)
70
+ path.start_with?("#{@root}/") && matches?(relative(path))
71
+ end
72
+
73
+ # True when the root-relative path matches a cover glob.
74
+ def matches?(relative_path)
75
+ @globs.any? do |glob|
76
+ File.fnmatch?(glob, relative_path, GLOB_FLAGS)
77
+ end
78
+ end
79
+
80
+ # path relative to root, eg "source/kata.rb".
81
+ def relative(path)
82
+ path.delete_prefix("#{@root}/")
83
+ end
84
+
85
+ # Refuses a setting that does not exist, naming it.
86
+ def method_missing(name, *)
87
+ raise ConfigurationError, [
88
+ "unknown setting #{name} in ConditionCoverage.start",
89
+ "the settings are: #{SETTINGS.join(', ')}"
90
+ ].join("\n")
91
+ end
92
+
93
+ # Answers only for the settings there are.
94
+ def respond_to_missing?(_name, _include_private = false)
95
+ false
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,42 @@
1
+ module ConditionCoverage
2
+ # The terminal worklist: one line per missing outcome, and
3
+ # nothing for a site seen both ways. Every count stays in
4
+ # the Result for reporters that show them all.
5
+ module Gaps
6
+ # The gap lines of result, eg "k.rb:2:2 operand 0 never
7
+ # false", in manifest order.
8
+ def self.lines(result)
9
+ result.operands.flat_map do |site, operands|
10
+ site_gaps(site, operands)
11
+ end
12
+ end
13
+
14
+ # One line for a site that never ran, else one per gap
15
+ # in its operands.
16
+ def self.site_gaps(site, operands)
17
+ if operands.all? { |counts| never_ran?(counts) }
18
+ return ["#{site} never reached"]
19
+ end
20
+
21
+ operands.each_with_index.flat_map do |counts, index|
22
+ operand_gap(counts).map do |gap|
23
+ "#{site} operand #{index} #{gap}"
24
+ end
25
+ end
26
+ end
27
+
28
+ # The one outcome this operand is missing, if any.
29
+ def self.operand_gap(counts)
30
+ return ['never evaluated'] if never_ran?(counts)
31
+ return ['never true'] if counts[:truthy].zero?
32
+ return ['never false'] if counts[:falsy].zero?
33
+
34
+ []
35
+ end
36
+
37
+ # True when the operand was never recorded either way.
38
+ def self.never_ran?(counts)
39
+ counts[:truthy].zero? && counts[:falsy].zero?
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>{{path}} · Condition coverage</title>
7
+ <style>
8
+ /*<![CDATA[*/
9
+ {{style}}
10
+ /*]]>*/
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <main class="page">
15
+ <p class="back"><a data-index="" href="{{index}}">Condition coverage</a></p>
16
+ <h1 class="path">{{path}}</h1>
17
+ <div class="box">
18
+ {{listing}}
19
+ </div>
20
+ </main>
21
+ <script>
22
+ //<![CDATA[
23
+ {{script}}
24
+ //]]>
25
+ </script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Condition coverage</title>
7
+ <style>
8
+ /*<![CDATA[*/
9
+ {{style}}
10
+ /*]]>*/
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <main class="page">
15
+ <h1>Condition coverage</h1>
16
+ <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">Outcomes seen</button></th>
23
+ <th class="num" aria-sort="none"><button type="button" data-column="3" data-type="number">Missing</button></th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ {{rows}}
28
+ </tbody>
29
+ </table>
30
+ </div>
31
+ </main>
32
+ <script>
33
+ //<![CDATA[
34
+ {{script}}
35
+ //]]>
36
+ </script>
37
+ </body>
38
+ </html>
@@ -0,0 +1,28 @@
1
+ // Sorts the files table by the clicked column's data-sort values;
2
+ // a second click on the same column reverses the order.
3
+ (function () {
4
+ var table = document.getElementById('files');
5
+ var body = table.tBodies[0];
6
+ var state = { column: -1, ascending: true };
7
+
8
+ table.querySelectorAll('thead button').forEach(function (button) {
9
+ button.addEventListener('click', function () {
10
+ var column = Number(button.dataset.column);
11
+ var numeric = button.dataset.type === 'number';
12
+ state.ascending = state.column === column ? !state.ascending : true;
13
+ state.column = column;
14
+ var rows = Array.from(body.rows);
15
+ rows.sort(function (a, b) {
16
+ var x = a.cells[column].dataset.sort;
17
+ var y = b.cells[column].dataset.sort;
18
+ var order = numeric ? Number(x) - Number(y) : x.localeCompare(y);
19
+ return state.ascending ? order : -order;
20
+ });
21
+ rows.forEach(function (row) { body.appendChild(row); });
22
+ table.querySelectorAll('thead th').forEach(function (th, i) {
23
+ var sort = state.ascending ? 'ascending' : 'descending';
24
+ th.setAttribute('aria-sort', i === column ? sort : 'none');
25
+ });
26
+ });
27
+ });
28
+ })();
@@ -0,0 +1,106 @@
1
+ /* Blue (seen both ways) and orange (missing) stay distinct for
2
+ red-green colour blindness; states also differ in form. */
3
+ :root {
4
+ --bg: #FBFBFC;
5
+ --surface: #FFFFFF;
6
+ --panel: #F2F4F7;
7
+ --fg: #1C2230;
8
+ --muted: #5B6577;
9
+ --rule: #D5DAE2;
10
+ --ok: #1F5FBF;
11
+ --ok-tint: #E6EEFA;
12
+ --warn: #A35200;
13
+ --bad: #B54708;
14
+ --on-status: #FFFFFF;
15
+ --link: #1F5FBF;
16
+ --font-body: "Helvetica Neue", Arial, sans-serif;
17
+ --font-code: Menlo, Consolas, "DejaVu Sans Mono", monospace;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #12151C; --surface: #181C25; --panel: #212633; --fg: #E4E8EF;
22
+ --muted: #9AA4B5; --rule: #323949; --ok: #7FB0FF; --ok-tint: #1A2740;
23
+ --warn: #F0A35E; --bad: #F08A3C; --on-status: #12151C; --link: #7FB0FF;
24
+ color-scheme: dark;
25
+ }
26
+ }
27
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 15px; }
28
+ a { color: var(--link); }
29
+ .page {
30
+ max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px;
31
+ padding: 40px max(16px, env(safe-area-inset-right, 0px)) 64px max(16px, env(safe-area-inset-left, 0px));
32
+ }
33
+ h1 { margin: 0; font-size: 28px; font-weight: 600; }
34
+ .box { border: 1px solid var(--rule); border-radius: 8px; background: var(--surface); overflow-x: auto; }
35
+ table { width: 100%; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; }
36
+ th, td { padding: 10px 16px; text-align: left; }
37
+ th { background: var(--panel); }
38
+ td { border-top: 1px solid var(--rule); }
39
+ td:first-child { font-family: var(--font-code); }
40
+ .num, td:not(:first-child) { text-align: right; }
41
+ th button {
42
+ font: 600 14px var(--font-body); color: var(--fg); background: none; border: 0;
43
+ padding: 4px 0; cursor: pointer;
44
+ }
45
+ th button::after { content: " \2195"; color: var(--muted); }
46
+ th[aria-sort="ascending"] button::after { content: " \25B2"; color: var(--fg); }
47
+ th[aria-sort="descending"] button::after { content: " \25BC"; color: var(--fg); }
48
+ th button:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
49
+
50
+ /* A file's page: plain source; on lines holding a condition the
51
+ line numbers are one button. Every number has the same box, so
52
+ digits stay aligned whether or not they are on a button. */
53
+ .back { margin: 0; font-size: 14px; }
54
+ h1.path { font-family: var(--font-code); font-size: 20px; }
55
+ .code { font-family: var(--font-code); font-size: 14px; line-height: 32px; padding: 10px 0; min-width: 720px; }
56
+ .line, .block { display: flex; align-items: flex-start; }
57
+ .srcs { display: flex; flex-direction: column; }
58
+ .src { white-space: pre; }
59
+ .lineno {
60
+ display: block; width: 52px; padding-right: 20px; text-align: right;
61
+ color: var(--muted); font-variant-numeric: tabular-nums; flex-shrink: 0;
62
+ }
63
+ button.lnum {
64
+ display: flex; flex-direction: column; flex-shrink: 0; margin: 4px 0; padding: 0;
65
+ border: 1px solid transparent; border-radius: 6px; cursor: pointer;
66
+ font: 600 14px/24px var(--font-code); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
67
+ }
68
+ button.lnum { position: relative; }
69
+ button.lnum::before {
70
+ content: "\25B8"; position: absolute; left: 7px; top: 4px;
71
+ font-size: 11px; line-height: 24px; transition: transform 120ms;
72
+ }
73
+ button.lnum[aria-expanded="true"]::before { transform: rotate(90deg); }
74
+ /* With a site count the count and the triangle share the
75
+ button's height, one 11px line each. */
76
+ button.lnum:has(.site-count)::before { top: 12px; line-height: 11px; }
77
+ /* On a button over two or more lines the triangle sits at the
78
+ very bottom. */
79
+ button.lnum:has(.lineno + .lineno)::before { top: auto; bottom: 2px; line-height: 11px; }
80
+ .site-count {
81
+ position: absolute; left: 7px; top: 1px;
82
+ font-size: 9px; line-height: 11px; font-weight: 700;
83
+ }
84
+ /* The rule between sites spans the tree's full width, through
85
+ its 16px side padding. */
86
+ .site + .site {
87
+ border-top: 1px solid var(--rule); margin: 6px -16px 0; padding: 6px 16px 0;
88
+ }
89
+ button.lnum .lineno { width: 50px; padding-right: 19px; color: inherit; line-height: 32px; margin: -4px 0; }
90
+ button.lnum[data-status="covered"] { background: var(--ok-tint); color: var(--ok); border-color: var(--ok); }
91
+ button.lnum[data-status="gaps"] { background: var(--bad); color: var(--on-status); border-color: var(--bad); }
92
+ button.lnum:hover { filter: brightness(1.1); }
93
+ button.lnum:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
94
+ .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;
97
+ }
98
+ .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); }
100
+ .row.headings span:first-child { grid-column: span 2; }
101
+ .row.headings span:nth-child(2), .row.headings span:nth-child(3) { text-align: right; }
102
+ .operator { grid-column: span 2; font-weight: 600; }
103
+ .branch { white-space: pre; color: var(--muted); margin-right: -16px; }
104
+ .row .text { white-space: pre; }
105
+ .row .truthy, .row .falsy { text-align: right; font-variant-numeric: tabular-nums; }
106
+ .row .gap { color: var(--bad); }
@@ -0,0 +1,12 @@
1
+ // Opens and closes the tree a line-number button names in its
2
+ // aria-controls, keeping aria-expanded in step.
3
+ (function () {
4
+ document.querySelectorAll('button[aria-controls]').forEach(function (button) {
5
+ button.addEventListener('click', function () {
6
+ var tree = document.getElementById(button.getAttribute('aria-controls'));
7
+ var open = button.getAttribute('aria-expanded') === 'true';
8
+ button.setAttribute('aria-expanded', open ? 'false' : 'true');
9
+ tree.hidden = open;
10
+ });
11
+ });
12
+ })();