path_coverage 0.0.2 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acac92dac750f3680a27856c6e410469f26c48c6ec23d6363d8b22caba5bc30c
4
- data.tar.gz: c19de09501f409d8209ddb1071f37e44b926d9a4381ec302a6c460961ff162f8
3
+ metadata.gz: 7146cc322217826aadc2bd040b560b5e77d6fad7b6de4d2ae43e8bdfaa893940
4
+ data.tar.gz: 29135bab12a1cc92d1a15dd8d383d3e79d75b3b8716fff7efa95fbbb6f8c3b34
5
5
  SHA512:
6
- metadata.gz: 2ec2945b4797a6b1f51f558c2cf2348307e1b6da68e5812c8ad4dfcab51f85926716f425c695cfd7456fec679d17fabf2e6b1400f7f6e131b0ccd9608aed5bce
7
- data.tar.gz: 196816d6db1d908d2c578f7c547a9464025187d03c344537b741106faee39aa6c0c1127759456989442497fbc7a33f773a0ca096f92e5aaa599cbec9949f27f6
6
+ metadata.gz: 4ebf32a5b048eb5d3d1b1cbbca0e2ed9c217e429481d1bd77a242f0a3baff83ee7b83a2b4a4cd99f6a6611885d1c165a56b6bd6f814be28c4cb6a92cffbc8282
7
+ data.tar.gz: 9ee6e1f2aea70b34ffb38ec5fa16a52f92ae5daf0a680727903db858fcdb557c244d0ae61a048e1a7bc09d99128c0b7068dd408686b2aca683b9f037afe3f6f3
data/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # path_coverage
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/path_coverage)](https://rubygems.org/gems/path_coverage)
4
+ [![Downloads](https://img.shields.io/gem/dt/path_coverage)](https://rubygems.org/gems/path_coverage)
5
+ [![License](https://img.shields.io/github/license/cyber-dojo/path_coverage)](LICENSE.md)
6
+
3
7
  Path coverage for Ruby, to run alongside SimpleCov's line and branch
4
8
  coverage.
5
9
 
6
10
  Gem: https://rubygems.org/gems/path_coverage (which shows the latest
7
11
  version).
8
12
 
9
- Status: sites cover `&&`, `||`, `and`, `or`, the ternary `?:`, and
10
- `if`/`elsif`/`else`: each leaf operand of a condition, however its
11
- operators are chained, nested or grouped. Method paths cover `if`
12
- decisions. Decisions hidden inside calls come next (see Roadmap).
13
+ Status: sites cover `&&`, `||`, `and`, `or`, the ternary `?:`,
14
+ `if`/`elsif`/`else` and `unless` (modifiers included), `case`/`when`
15
+ with a subject, and the block methods `any?` `all?` `none?` `find`
16
+ `detect` `find_index` `take_while` `select` `filter` `reject`
17
+ `partition` `count` `filter_map`. Method paths cover `if` decisions.
18
+ More decisions hidden inside calls come next (see Roadmap).
13
19
 
14
20
  Demo: https://cyber-dojo.github.io/path_coverage/ shows the HTML report
15
21
  for the mock source tree in `demo/`. `make demo` builds and opens the
@@ -65,7 +71,7 @@ PathCoverage.start do
65
71
  root APP_DIR # cover globs and site names are relative to it
66
72
  cover 'source/**/*.rb' # each matching file is instrumented as it loads
67
73
  coverage_dir '/tmp/coverage' # where path_coverage.json is written
68
- measure :conditions, :paths # sites alone unless :paths is named too
74
+ measure :decisions, :methods # :decisions alone unless :methods is named
69
75
  report :gaps, :html # the reporters run at exit (default :gaps)
70
76
  end
71
77
  ```
@@ -93,9 +99,18 @@ each report POSTed to it as a numbered file, and once every run is done
93
99
  they render as one report:
94
100
 
95
101
  ```sh
96
- path_coverage_collector 4570 /tmp/paths/runs # until stopped
102
+ path_coverage_collector 4570 /tmp/paths/runs # until sent TERM
97
103
  ```
98
104
 
105
+ - A report is saved before it is answered 200, so a 200 means it is on
106
+ disk. A report cut short, eg by a sender dying mid-send, is answered
107
+ 400 and not saved. One that cannot be saved is answered 500.
108
+ - `GET /alive` answers 200 while the collector runs.
109
+ - `GET /ready` answers 200 when its dir is writable, 503 when not. Wait
110
+ for it before starting the runs.
111
+ - On TERM it stops accepting connections, finishes the report in hand,
112
+ and exits 0.
113
+
99
114
  ```ruby
100
115
  runs = Dir['/tmp/paths/runs/*.json']
101
116
  PathCoverage::Render.from_json(config, runs)
@@ -124,7 +139,9 @@ source/kata.rb:4:24 never reached
124
139
  source/fee.rb:2:2 fee path never taken: line 4 true, line 7 false
125
140
  ```
126
141
 
127
- ### Sites
142
+ ### Decisions
143
+
144
+ Measured always, as `:decisions`: the paths within each decision.
128
145
 
129
146
  A site is named by the `path:line:column` where it starts, the path
130
147
  relative to `root`. A site in a covered file the run never loaded is
@@ -157,7 +174,7 @@ across the whole of it, nested conditions included:
157
174
  ok?(id) && new?(id) && id.size == 6 # one && site, leaves 0 1 2
158
175
  ok?(id) && (new?(id) || id.size == 6) # an || nested in an && site, 0 1 2
159
176
  ok?(id) && new?(id) || id.size == 6 # an && nested in an || site, 0 1 2
160
- ids.any? { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
177
+ ids.map { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
161
178
  ok?(id) ? :ok : :no # a ?: site, its predicate leaf 0
162
179
  ok?(id) && new?(id) ? :ok : :no # an && nested in a ?: site, 0 1
163
180
  ok?(id) ? new?(id) && id.size == 6 : :no # one ?: site, its then arm 1 2
@@ -170,12 +187,30 @@ paths run through whichever arm the predicate picks. Any other arm, like a
170
187
  block, is not part of it, though a condition inside it is a site of its
171
188
  own.
172
189
 
173
- An `if` (with its `end`, not a modifier) is a site too, even when its
174
- predicate is a single leaf: its predicate nests in it, an `&&` there
190
+ An `if` or an `unless`, a modifier included, is a site too, even when
191
+ its predicate is a single leaf: its predicate nests in it, an `&&` there
175
192
  being no site of its own, and its `then` and `else` arms are plain, as
176
- they are statements. Its extent is the `if` keyword to the end of its
177
- predicate, so a site inside an arm keeps its own button. An `elsif` is a
178
- site of its own.
193
+ they are statements; an `unless` takes its `then` arm when its predicate
194
+ is false. It is named at its keyword, and its extent is its keyword to
195
+ the end of its predicate, so a site inside an arm keeps its own button.
196
+ An `elsif` is a site of its own.
197
+
198
+ A `case` with a subject is a site whose leaves are its `when` patterns,
199
+ tried in order until one matches. Each pattern is wrapped in a stand-in
200
+ whose `===` calls the pattern's `===` once, exactly as the `case` would,
201
+ and notes whether it matched. A `case` with no subject, or with a splat
202
+ among its patterns, is no site.
203
+
204
+ A call to a block method, with no arguments and a block of one
205
+ expression, is a site named at its method. Its two leaves are its
206
+ receiver, showing how many items its block was called with (`0 items`,
207
+ `1 item` or `2+ items`), and its block's expression: for `any?` `all?`
208
+ `none?` `find` `detect` `find_index` and `take_while`, which stop once
209
+ their block decides, its last answer; for `select` `filter` `reject`
210
+ `partition` `count` and `filter_map`, which call it with every item,
211
+ whether its answers were `all true`, `all false` or `both`. Both are
212
+ noted from the block's own calls, so the receiver is never asked for its
213
+ size, which could consume a sequence that can be read only once.
179
214
 
180
215
  A chain of one operator is flat; a change of operator, by precedence or by
181
216
  parentheses, nests. Parentheses are not part of the tree, since they decide
@@ -195,7 +230,7 @@ constants, since one can hold nil or false (`VERBOSE = false`). `nil` and
195
230
 
196
231
  ### Methods
197
232
 
198
- With `measure :conditions, :paths`, each method with two or more
233
+ With `measure :decisions, :methods`, each method with two or more
199
234
  decisions also counts each call's path through them: the outcome of each
200
235
  decision it reached, in order. A method is named by the `path:line:column`
201
236
  of its `def`, then its name. Each decision is one outcome on the path,
@@ -207,8 +242,11 @@ leaves the method ends the path there.
207
242
  ### Reporters
208
243
 
209
244
  - `:html` writes `index.html` to `coverage_dir`: the covered files with
210
- their sites, outcomes seen (with a percent) and outcomes missing, each
211
- column sortable. Each file name opens `files/<path>.html`, the file's
245
+ their sites and their paths taken of all possible, within each decision
246
+ and, when `:methods` is measured, across a method's decisions (eg
247
+ `9 / 28 (32%)`, or `-` for a file with no method of two or more
248
+ decisions), each column sortable, and a Total row for every file
249
+ together. Each file name opens `files/<path>.html`, the file's
212
250
  source. On a line holding a site the line number is a button (blue
213
251
  and outlined when every outcome was seen, solid orange when any is
214
252
  missing) that opens a grid of the site's paths, on the left. Its rows
@@ -275,16 +313,15 @@ the run never loaded are read and parsed then.
275
313
  ## Roadmap
276
314
 
277
315
  - decisions hidden inside calls, each with its own outcome classes:
278
- - `any?` / `all?` / `none?`: empty, decided by the first item, decided
279
- later, never decided
280
- - `find` / `detect`: found vs nil
316
+ - `one?`, whose last answer does not decide it, and block methods
317
+ with blocks of more than one expression
281
318
  - `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
282
319
  - `x || default` in value context, `&.` safe navigation: nil vs not nil
283
320
  - `||=` and `&&=`, on variables, attributes and index targets: the
284
321
  right side evaluated vs skipped, counted without evaluating the
285
322
  target twice
286
323
  - `x rescue y` modifier: raised vs not raised
287
- - `unless`, modifier `if`, `case`/`when` and `in` patterns
324
+ - `case` with no subject, and `case`/`in` patterns
288
325
  - regex matches: nil vs MatchData
289
326
 
290
327
  ## Constraints
@@ -9,7 +9,13 @@ if ARGV.first == '-h'
9
9
 
10
10
  Listens on PORT and saves each JSON report the :post reporter
11
11
  sends as DIR/run-0001.json, DIR/run-0002.json, and so on,
12
- creating DIR if need be. Runs until stopped.
12
+ creating DIR if need be. A 200 means the report is saved.
13
+
14
+ GET /alive answers 200 while it runs.
15
+ GET /ready answers 200 when DIR is writable, 503 when not.
16
+
17
+ Runs until sent TERM. It then stops accepting connections,
18
+ finishes the report in hand, and exits 0.
13
19
 
14
20
  Options:
15
21
  -h Show this help
@@ -23,4 +29,9 @@ end
23
29
  port, dir = ARGV
24
30
  FileUtils.mkdir_p(dir)
25
31
  server = TCPServer.new('0.0.0.0', Integer(port))
32
+ # Closing the server on TERM makes serve return once the
33
+ # report in hand, if any, is saved and answered. The close
34
+ # runs in a thread of its own, as the trap runs on the thread
35
+ # that may be waiting in accept, where a close deadlocks.
36
+ Signal.trap('TERM') { Thread.new { server.close } }
26
37
  PathCoverage::Collector.serve(server, dir)
@@ -0,0 +1,67 @@
1
+ # The calls rewritten code makes, each handed to the kernel's
2
+ # one recorder. Like the kernel, this file loads before any
3
+ # covered file and is never itself instrumented.
4
+ module PathCoverage
5
+ # The call rewritten code makes around each operand; returns
6
+ # value unchanged.
7
+ def self.record(site, index, value)
8
+ @recorder.record(site, index, value)
9
+ end
10
+
11
+ # The call rewritten code makes before a site's first leaf,
12
+ # site having size leaves; returns the evaluation's trail.
13
+ def self.enter(site, size)
14
+ @recorder.enter(site, size)
15
+ end
16
+
17
+ # The call rewritten code makes around a site as a whole,
18
+ # with the trail enter gave it; returns value unchanged.
19
+ def self.leave(trail, value)
20
+ @recorder.leave(trail, value)
21
+ end
22
+
23
+ # The call rewritten code makes around a case's pattern, the
24
+ # index-th of size at site; returns the pattern's stand-in.
25
+ def self.when_match(site, index, size, pattern)
26
+ @recorder.when_match(site, index, size, pattern)
27
+ end
28
+
29
+ # The call rewritten code makes before a block method's call
30
+ # at site runs; returns the evaluation's trail.
31
+ def self.enter_items(site)
32
+ @recorder.enter_items(site)
33
+ end
34
+
35
+ # The call rewritten code makes around a block method's
36
+ # block's expression at site; returns value unchanged.
37
+ def self.item(site, value)
38
+ @recorder.item(site, value)
39
+ end
40
+
41
+ # The call rewritten code makes around the block's expression
42
+ # of a block method that calls it with every item; returns
43
+ # value unchanged.
44
+ def self.answer(site, value)
45
+ @recorder.answer(site, value)
46
+ end
47
+
48
+ # The call rewritten code makes around a block method's call
49
+ # as a whole, with the trail enter_items gave it; returns
50
+ # value unchanged.
51
+ def self.leave_items(trail, value)
52
+ @recorder.leave_items(trail, value)
53
+ end
54
+
55
+ # The call rewritten code makes as a call reaches its
56
+ # method's first decision; returns the call's new path.
57
+ def self.path(site, size)
58
+ @recorder.path(site, size)
59
+ end
60
+
61
+ # The call rewritten code makes around each decision's
62
+ # predicate, with exits for one whose arm leaves the method;
63
+ # returns value unchanged.
64
+ def self.decide(path, index, value, exits = nil)
65
+ @recorder.decide(path, index, value, exits)
66
+ end
67
+ end
@@ -3,44 +3,110 @@ module PathCoverage
3
3
  # runs that sent them, eg failing docker build steps, can
4
4
  # be merged and rendered once they are all done.
5
5
  module Collector
6
- # Answers every request on server, forever, saving each
7
- # body in dir as run-0001.json, run-0002.json, and so on,
8
- # in the order they arrive. A connection sending no
9
- # request, eg a check that the collector is up, is skipped.
6
+ # Answers every request on server until server is closed,
7
+ # then returns.
8
+ # Each report is saved in dir as run-0001.json,
9
+ # run-0002.json, and so on, in the order they arrive.
10
+ # A connection sending no request is skipped.
10
11
  def self.serve(server, dir)
11
12
  n = 0
12
- loop do
13
- body = request_body(server.accept)
14
- next if body.nil?
15
-
16
- n += 1
17
- File.write(format('%<dir>s/run-%<n>04d.json', dir:, n:), body)
13
+ while (client = accept(server))
14
+ saved = receive(client, dir) do |body|
15
+ save(dir, format('run-%<n>04d.json', n: n + 1), body)
16
+ end
17
+ n += 1 if saved
18
18
  end
19
19
  end
20
20
 
21
- # The body of the one HTTP request on client, after
22
- # answering it 200; nil, unanswered, when client closes
23
- # without sending a request.
24
- def self.request_body(client)
21
+ # The next connection to server; nil once server is closed,
22
+ # which is how serve is told to return.
23
+ def self.accept(server)
24
+ server.accept
25
+ rescue IOError
26
+ nil
27
+ end
28
+
29
+ # Saves body as dir/name whole or not at all: it is written
30
+ # to name.tmp and renamed, so a collector stopped mid-write
31
+ # leaves no partial report under a run's name.
32
+ def self.save(dir, name, body)
33
+ path = "#{dir}/#{name}"
34
+ File.write("#{path}.tmp", body)
35
+ File.rename("#{path}.tmp", path)
36
+ end
37
+
38
+ # Hands the body of the one HTTP request on client to the
39
+ # block, which saves it, and returns whether it was saved.
40
+ # dir is where reports are saved, which GET /ready checks.
41
+ # A client that closes without sending a request, eg a
42
+ # check that the collector is up, is closed unanswered and
43
+ # nothing is saved.
44
+ def self.receive(client, dir, &)
25
45
  headers = []
26
46
  while (line = client.gets) && line != "\r\n"
27
47
  headers << line
28
48
  end
29
- body = headers.empty? ? nil : answer(client, headers)
49
+ saved = !headers.empty? && respond(client, headers, dir, &)
30
50
  client.close
31
- body
51
+ saved
52
+ end
53
+
54
+ # The check each GET path makes of the collector's dir.
55
+ # GET /alive passes whenever the collector answers.
56
+ # GET /ready passes only when dir is a writable directory,
57
+ # so a report sent now could be saved.
58
+ PROBES = {
59
+ '/alive' => ->(_dir) { true },
60
+ '/ready' => lambda do |dir|
61
+ File.directory?(dir) && File.writable?(dir)
62
+ end
63
+ }.freeze
64
+
65
+ # Answers the request with headers on client, and returns
66
+ # whether a report was saved in dir.
67
+ # A GET of a path in PROBES is answered 200 when its check
68
+ # passes, 503 when not. Nothing is saved for it.
69
+ # Any other request is a report, handed to answer.
70
+ def self.respond(client, headers, dir, &)
71
+ probe = PROBES[headers.first[/\AGET (\S+) /, 1]]
72
+ return answer(client, headers, &) if probe.nil?
73
+
74
+ up = probe.call(dir)
75
+ reply(client, up ? '200 OK' : '503 Service Unavailable')
76
+ false
32
77
  end
33
78
 
34
- # The body of a request with headers, read from client,
35
- # which is then answered 200.
79
+ # Reads the body of a request with headers from client and
80
+ # yields it to be saved. Returns whether it was saved.
81
+ # The 200 is sent only once it is saved.
82
+ # So a 200 always means the report is on disk.
83
+ # A body shorter than its Content-Length is answered 400.
84
+ # Eg its sender died mid-send. It is not saved.
85
+ # A save that fails is answered 500.
36
86
  def self.answer(client, headers)
37
- length = headers.sum do |line|
87
+ length = content_length(headers)
88
+ body = client.read(length).to_s
89
+ whole = body.bytesize == length
90
+ yield body if whole
91
+ reply(client, whole ? '200 OK' : '400 Bad Request')
92
+ whole
93
+ rescue SystemCallError
94
+ reply(client, '500 Internal Server Error')
95
+ false
96
+ end
97
+
98
+ # The byte length headers give the request's body; 0 when
99
+ # they give none.
100
+ def self.content_length(headers)
101
+ headers.sum do |line|
38
102
  name, value = line.split(':', 2)
39
103
  name.casecmp?('content-length') ? value.to_i : 0
40
104
  end
41
- body = client.read(length)
42
- client.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
43
- body
105
+ end
106
+
107
+ # Writes an HTTP response of status, with no body, to client.
108
+ def self.reply(client, status)
109
+ client.write("HTTP/1.1 #{status}\r\nContent-Length: 0\r\n\r\n")
44
110
  end
45
111
  end
46
112
  end
@@ -1,9 +1,60 @@
1
1
  module PathCoverage
2
2
  # The operators whose predicate picks an arm.
3
- CHOICES = %w[?: if].freeze
3
+ CHOICES = %w[?: if unless].freeze
4
4
 
5
- # One arm of a ternary or an if: kind is :then or :else,
6
- # extent its byte range, nil for an if's arm with no
5
+ # The choices whose arms are statements, so whose path is
6
+ # decided by their predicate alone.
7
+ STATEMENT_CHOICES = %w[if unless].freeze
8
+
9
+ # The predicate outcome on which each choice takes its then
10
+ # arm: an unless's is false.
11
+ THEN_ON = Hash.new(true).merge('unless' => false).freeze
12
+
13
+ # The methods that call their block an item at a time until
14
+ # it decides, its last answer deciding the result.
15
+ STOP_EARLY = %w[
16
+ any? all? none? find detect find_index take_while
17
+ ].freeze
18
+
19
+ # How many items a block method's block was called with, as
20
+ # its receiver's leaf outcome: none, one, or two or more.
21
+ ITEMS = ['0 items', '1 item', '2+ items'].freeze
22
+
23
+ # Every path a stop-early block method's call can take: how
24
+ # many items its block was called with, and its last answer,
25
+ # nil when it was never called.
26
+ STOP_EARLY_PATHS = [
27
+ [ITEMS[0], nil],
28
+ [ITEMS[1], true], [ITEMS[1], false],
29
+ [ITEMS[2], true], [ITEMS[2], false]
30
+ ].freeze
31
+
32
+ # The methods that call their block with every item.
33
+ EVERY_ITEM = %w[
34
+ select filter reject partition count filter_map
35
+ ].freeze
36
+
37
+ # A block's answers over every item it was called with.
38
+ ANSWERS = ['all true', 'all false', 'both'].freeze
39
+
40
+ # Every path an every-item block method's call can take: how
41
+ # many items its block was called with, and its answers,
42
+ # which can be both only with two or more items.
43
+ EVERY_ITEM_PATHS = [
44
+ [ITEMS[0], nil],
45
+ [ITEMS[1], ANSWERS[0]], [ITEMS[1], ANSWERS[1]],
46
+ [ITEMS[2], ANSWERS[0]], [ITEMS[2], ANSWERS[1]],
47
+ [ITEMS[2], ANSWERS[2]]
48
+ ].freeze
49
+
50
+ # Every block method whose call is a site, by its paths.
51
+ BLOCK_PATHS = [
52
+ *STOP_EARLY.map { |name| [name, STOP_EARLY_PATHS] },
53
+ *EVERY_ITEM.map { |name| [name, EVERY_ITEM_PATHS] }
54
+ ].to_h.freeze
55
+
56
+ # One arm of a ternary, an if or an unless: kind is :then or
57
+ # :else, extent its byte range, nil for an arm with no
7
58
  # statements, and body the Condition it is, when it is one,
8
59
  # else nil for a plain value or statements, which decide
9
60
  # nothing.
@@ -9,25 +9,28 @@ module PathCoverage
9
9
  collector cover coverage_dir measure report root
10
10
  ].freeze
11
11
  REPORTERS = %w[gaps html post].freeze
12
- MEASURES = %w[conditions paths].freeze
12
+ MEASURES = %w[decisions methods].freeze
13
13
 
14
14
  # Evaluates the settings block against a new configuration.
15
15
  def initialize(&)
16
16
  @globs = []
17
17
  @reporters = [:gaps]
18
- @measures = [:conditions]
18
+ @measures = [:decisions]
19
19
  instance_eval(&)
20
20
  end
21
21
 
22
22
  # The reporters chosen with report, :gaps when none were.
23
23
  attr_reader :reporters
24
24
 
25
- # What is measured, chosen with measure, :conditions when
26
- # nothing was.
25
+ # What is measured, chosen with measure.
26
+ # :decisions, the paths within each decision, when nothing
27
+ # was chosen.
27
28
  attr_reader :measures
28
29
 
29
- # Chooses what is measured, eg measure :conditions, :paths;
30
- # refuses a name there is no measure for.
30
+ # Chooses what is measured.
31
+ # Eg measure :decisions, :methods also measures the paths
32
+ # across each method's decisions.
33
+ # Refuses a name there is no measure for.
31
34
  def measure(*names)
32
35
  check('measure', names, MEASURES)
33
36
  @measures = names.map(&:to_sym)
@@ -19,13 +19,16 @@
19
19
  <tr>
20
20
  <th aria-sort="none"><button type="button" data-column="0" data-type="text">File</button></th>
21
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>
22
+ <th class="num" aria-sort="none"><button type="button" data-column="2" data-type="number">Within each decision</button></th>
23
+ {{across}}
24
24
  </tr>
25
25
  </thead>
26
26
  <tbody>
27
27
  {{rows}}
28
28
  </tbody>
29
+ <tfoot>
30
+ {{total}}
31
+ </tfoot>
29
32
  </table>
30
33
  </div>
31
34
  </main>
@@ -71,19 +71,30 @@
71
71
  });
72
72
  var side = !panel ? '' : panel.classList.contains('tree') ? 'cc' : 'pc';
73
73
  document.body.setAttribute('data-open', side);
74
- slide();
74
+ slide(panel);
75
+ }
76
+
77
+ // Scrolls the page just enough to show all of panel when it
78
+ // is a tree, eg one opened from a line near the bottom of the
79
+ // window, so its last rows are not below the fold. Its first
80
+ // row stays level with its site's line.
81
+ function showWhole(panel) {
82
+ if (panel && panel.classList.contains('tree')) {
83
+ panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
84
+ }
75
85
  }
76
86
 
77
87
  // How long a rail takes to open or close, as style.css has it.
78
88
  var SLIDE_MS = 250;
79
89
 
80
90
  // Places everything on every frame while the rails slide, as
81
- // the bars and bands follow the code across.
82
- function slide() {
91
+ // the bars and bands follow the code across, then shows all
92
+ // of panel, the one just opened, once it is in its place.
93
+ function slide(panel) {
83
94
  var end = performance.now() + SLIDE_MS + 50;
84
95
  function frame(now) {
85
96
  place();
86
- if (now < end) { requestAnimationFrame(frame); }
97
+ if (now < end) { requestAnimationFrame(frame); } else { showWhole(panel); }
87
98
  }
88
99
  requestAnimationFrame(frame);
89
100
  }
@@ -1,5 +1,7 @@
1
1
  // Sorts the files table by the clicked column's data-sort values;
2
- // a second click on the same column reverses the order.
2
+ // a second click on the same column reverses the order. A cell
3
+ // with an empty data-sort, one with no value such as a -, stays
4
+ // last in both orders, so either end of the values can be first.
3
5
  (function () {
4
6
  var table = document.getElementById('files');
5
7
  var body = table.tBodies[0];
@@ -15,6 +17,7 @@
15
17
  rows.sort(function (a, b) {
16
18
  var x = a.cells[column].dataset.sort;
17
19
  var y = b.cells[column].dataset.sort;
20
+ if (x === '' || y === '') { return (x === '') - (y === ''); }
18
21
  var order = numeric ? Number(x) - Number(y) : x.localeCompare(y);
19
22
  return state.ascending ? order : -order;
20
23
  });
@@ -46,6 +46,10 @@ th, td { padding: 10px 16px; text-align: left; }
46
46
  th { background: var(--panel); }
47
47
  td { border-top: 1px solid var(--rule); }
48
48
  td:first-child { font-family: var(--font-code); }
49
+ /* The Total row frames the table's foot as the headings do its
50
+ head, so it reads as a sum, not as one more file. */
51
+ tfoot td { background: var(--panel); font-weight: 600; border-top: 1px solid var(--fg); }
52
+ tfoot td:first-child { font-family: inherit; }
49
53
  .num, td:not(:first-child) { text-align: right; }
50
54
  th button {
51
55
  font: 600 13px var(--font-body); color: var(--fg); background: none; border: 0;