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 +4 -4
- data/README.md +117 -40
- data/lib/condition_coverage/condition.rb +20 -0
- data/lib/condition_coverage/configuration.rb +98 -0
- data/lib/condition_coverage/gaps.rb +42 -0
- data/lib/condition_coverage/html/file.html +27 -0
- data/lib/condition_coverage/html/index.html +38 -0
- data/lib/condition_coverage/html/sort.js +28 -0
- data/lib/condition_coverage/html/style.css +106 -0
- data/lib/condition_coverage/html/toggle.js +12 -0
- data/lib/condition_coverage/html_file.rb +159 -0
- data/lib/condition_coverage/html_index.rb +43 -0
- data/lib/condition_coverage/html_report.rb +46 -0
- data/lib/condition_coverage/html_template.rb +20 -0
- data/lib/condition_coverage/json_writer.rb +13 -0
- data/lib/condition_coverage/kernel.rb +111 -0
- data/lib/condition_coverage/manifest.rb +107 -0
- data/lib/condition_coverage/recorder.rb +24 -0
- data/lib/condition_coverage/result.rb +31 -0
- data/lib/condition_coverage/rewriter.rb +39 -0
- data/lib/condition_coverage/site.rb +11 -0
- data/lib/condition_coverage/site_tree.rb +39 -0
- data/lib/condition_coverage/source_lines.rb +81 -0
- data/lib/condition_coverage/summary.rb +32 -0
- data/lib/condition_coverage/version.rb +3 -0
- data/lib/condition_coverage.rb +2 -3
- metadata +31 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0b1aba7a743838c35d3b4d139b40efebafd48edef44b82ffc48f7c045eb6861
|
|
4
|
+
data.tar.gz: '074496c6beaf83b9c6ec438019eb20e7b4f8231230ba94cd5e7a87cc2dc01eb1'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
23
|
-
`&&` / `||` operand is wrapped in a recorder call, before compilation:
|
|
26
|
+
## Use
|
|
24
27
|
|
|
25
28
|
```ruby
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
+
})();
|