path_coverage 0.0.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +22 -0
  3. data/README.md +353 -0
  4. data/exe/path_coverage_collector +37 -0
  5. data/lib/path_coverage/auto.rb +5 -0
  6. data/lib/path_coverage/calls.rb +67 -0
  7. data/lib/path_coverage/collector.rb +112 -0
  8. data/lib/path_coverage/condition.rb +96 -0
  9. data/lib/path_coverage/configuration.rb +130 -0
  10. data/lib/path_coverage/gaps.rb +60 -0
  11. data/lib/path_coverage/html/file.html +33 -0
  12. data/lib/path_coverage/html/index.html +41 -0
  13. data/lib/path_coverage/html/side.js +542 -0
  14. data/lib/path_coverage/html/sort.js +31 -0
  15. data/lib/path_coverage/html/style.css +338 -0
  16. data/lib/path_coverage/html/toggle.js +12 -0
  17. data/lib/path_coverage/html_file.rb +109 -0
  18. data/lib/path_coverage/html_index.rb +74 -0
  19. data/lib/path_coverage/html_nav.rb +67 -0
  20. data/lib/path_coverage/html_paths.rb +138 -0
  21. data/lib/path_coverage/html_report.rb +118 -0
  22. data/lib/path_coverage/html_template.rb +20 -0
  23. data/lib/path_coverage/html_tree.rb +146 -0
  24. data/lib/path_coverage/json_writer.rb +25 -0
  25. data/lib/path_coverage/kernel.rb +165 -0
  26. data/lib/path_coverage/manifest.rb +205 -0
  27. data/lib/path_coverage/manifest_blocks.rb +57 -0
  28. data/lib/path_coverage/manifest_case.rb +54 -0
  29. data/lib/path_coverage/manifest_choices.rb +132 -0
  30. data/lib/path_coverage/merge.rb +35 -0
  31. data/lib/path_coverage/path_gaps.rb +41 -0
  32. data/lib/path_coverage/path_manifest.rb +110 -0
  33. data/lib/path_coverage/paths_report.rb +55 -0
  34. data/lib/path_coverage/post.rb +26 -0
  35. data/lib/path_coverage/recorder.rb +182 -0
  36. data/lib/path_coverage/render.rb +92 -0
  37. data/lib/path_coverage/result.rb +59 -0
  38. data/lib/path_coverage/rewriter.rb +166 -0
  39. data/lib/path_coverage/site.rb +16 -0
  40. data/lib/path_coverage/site_paths.rb +86 -0
  41. data/lib/path_coverage/site_tree.rb +134 -0
  42. data/lib/path_coverage/source_lines.rb +79 -0
  43. data/lib/path_coverage/summary.rb +52 -0
  44. data/lib/path_coverage/totals.rb +29 -0
  45. data/lib/path_coverage/version.rb +3 -0
  46. data/lib/path_coverage.rb +2 -5
  47. metadata +73 -14
@@ -0,0 +1,338 @@
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
+ /* The tree is inverted from the page, so it never reads as
17
+ more source lines. */
18
+ --tree-bg: #1E2638;
19
+ --tree-fg: #E8ECF4;
20
+ --tree-muted: #A3AEC2;
21
+ --tree-rule: #3A4560;
22
+ --tree-gap: #F5A25D;
23
+ --font-body: "Helvetica Neue", Arial, sans-serif;
24
+ --font-code: Menlo, Consolas, "DejaVu Sans Mono", monospace;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --bg: #12151C; --surface: #181C25; --panel: #212633; --fg: #E4E8EF;
29
+ --muted: #9AA4B5; --rule: #323949; --ok: #7FB0FF; --ok-tint: #1A2740;
30
+ --warn: #F0A35E; --bad: #F08A3C; --on-status: #12151C; --link: #7FB0FF;
31
+ --tree-bg: #E6EAF2; --tree-fg: #1C2230; --tree-muted: #4E5869;
32
+ --tree-rule: #BCC4D2; --tree-gap: #A8480A;
33
+ color-scheme: dark;
34
+ }
35
+ }
36
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 14px; }
37
+ a { color: var(--link); }
38
+ .page {
39
+ max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px;
40
+ padding: 40px max(16px, env(safe-area-inset-right, 0px)) 64px max(16px, env(safe-area-inset-left, 0px));
41
+ }
42
+ h1 { margin: 0; font-size: 24px; font-weight: 600; }
43
+ .box { border: 1px solid var(--rule); border-radius: 8px; background: var(--surface); overflow-x: auto; }
44
+ table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
45
+ th, td { padding: 10px 16px; text-align: left; }
46
+ th { background: var(--panel); }
47
+ td { border-top: 1px solid var(--rule); }
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; }
53
+ .num, td:not(:first-child) { text-align: right; }
54
+ th button {
55
+ font: 600 13px var(--font-body); color: var(--fg); background: none; border: 0;
56
+ padding: 4px 0; cursor: pointer;
57
+ }
58
+ th button::after { content: " \2195"; color: var(--muted); }
59
+ th[aria-sort="ascending"] button::after { content: " \25B2"; color: var(--fg); }
60
+ th[aria-sort="descending"] button::after { content: " \25BC"; color: var(--fg); }
61
+ th button:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
62
+
63
+ /* A file's page: plain source; on lines holding a condition the
64
+ line numbers are one button. Every number has the same box, so
65
+ digits stay aligned whether or not they are on a button. */
66
+ .back { margin: 0; font-size: 13px; }
67
+ h1.path { font-family: var(--font-code); font-size: 18px; }
68
+ /* The back link, path and site lists stick together as one
69
+ header. */
70
+ .head {
71
+ position: sticky; top: 0; z-index: 5;
72
+ display: flex; flex-direction: column; gap: 10px;
73
+ margin: 0 -16px; padding: 12px 16px; background: var(--bg);
74
+ border-bottom: 1px solid var(--rule);
75
+ }
76
+ .jumps { display: flex; flex-direction: column; gap: 8px; }
77
+ .jump-list { display: flex; gap: 6px; align-items: center; overflow-x: auto; white-space: nowrap; min-width: 0; }
78
+ .jump-label { width: 64px; flex-shrink: 0; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
79
+ a.jump {
80
+ font: 600 12px/22px var(--font-code); padding: 0 9px; border-radius: 999px;
81
+ text-decoration: none; border: 1px solid; font-variant-numeric: tabular-nums;
82
+ }
83
+ /* A site's jump wears the condition tint, edged in its status
84
+ colour on the top and left only, as its line's button is. */
85
+ a.jump[data-status] { background: var(--cond-tint); border-right-color: transparent; border-bottom-color: transparent; }
86
+ a.jump[data-status="covered"] { color: var(--ok); border-top-color: var(--ok); border-left-color: var(--ok); }
87
+ a.jump[data-status="gaps"] { color: var(--bad); border-top-color: var(--bad); border-left-color: var(--bad); }
88
+ a.jump:focus-visible, .nav a:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
89
+ /* The source pane's border and background start after the nav
90
+ column, so the arrows sit outside the pane yet scroll with
91
+ their line. */
92
+ .box:has(> .code) { position: relative; isolation: isolate; border: 0; background: none; border-radius: 0; }
93
+ .box:has(> .code)::before {
94
+ content: ""; position: absolute; inset: 0 0 0 26px; z-index: -1;
95
+ border: 1px solid var(--rule); border-radius: 8px;
96
+ background: var(--surface); pointer-events: none;
97
+ }
98
+ /* The previous-gap arrow sits above the next, each half a line
99
+ tall, keeping the gutter narrow. */
100
+ .nav { display: flex; flex-direction: column; justify-content: center; width: 26px; flex-shrink: 0; }
101
+ .nav a { width: 22px; text-align: center; line-height: 14px; font-size: 12px; color: var(--bad); text-decoration: none; }
102
+ .nav a:hover { color: var(--fg); }
103
+ /* Clears the sticky header when a link jumps to a line. */
104
+ .lineno { scroll-margin-top: 190px; }
105
+ button.lnum:has(.lineno:target) { outline: 3px solid var(--link); outline-offset: 1px; }
106
+ .code { font-family: var(--font-code); font-size: 13px; line-height: 32px; padding: 10px 0; min-width: 720px; }
107
+ .line, .block { display: flex; align-items: flex-start; }
108
+ .srcs { display: flex; flex-direction: column; }
109
+ .src { white-space: pre; }
110
+ .lineno {
111
+ display: block; width: 52px; padding-right: 20px; text-align: right;
112
+ color: var(--muted); font-variant-numeric: tabular-nums; flex-shrink: 0;
113
+ }
114
+ button.lnum {
115
+ display: flex; flex-direction: column; flex-shrink: 0; margin: 4px 8px 4px 0; padding: 0;
116
+ border: 1px solid transparent; border-radius: 6px; cursor: pointer;
117
+ font: 600 13px/24px var(--font-code); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
118
+ }
119
+ button.lnum { position: relative; }
120
+ button.lnum::before {
121
+ content: "\25B8"; position: absolute; left: 7px; top: 4px;
122
+ font-size: 11px; line-height: 24px; transition: transform 120ms;
123
+ }
124
+ button.lnum[aria-expanded="true"]::before { transform: rotate(90deg); }
125
+ /* With a site count the count and the triangle share the
126
+ button's height, one 11px line each. */
127
+ button.lnum:has(.site-count)::before { top: 12px; line-height: 11px; }
128
+ /* On a button over two or more lines the triangle sits at the
129
+ very bottom. */
130
+ button.lnum:has(.lineno + .lineno)::before { top: auto; bottom: 2px; line-height: 11px; }
131
+ .site-count {
132
+ position: absolute; left: 7px; top: 1px;
133
+ font-size: 9px; line-height: 11px; font-weight: 700;
134
+ }
135
+ /* The rule between sites spans the tree's full width, through
136
+ its 16px side padding. */
137
+ .site + .site {
138
+ border-top: 1px solid var(--tree-rule); margin: 6px -16px 0; padding: 6px 16px 0;
139
+ }
140
+ button.lnum .lineno { width: 50px; padding-right: 11px; color: inherit; line-height: 32px; margin: -4px 0; }
141
+ /* A site's button wears the condition tint, edged in its
142
+ status colour on the top and left only, as a method's bar is. */
143
+ button.lnum[data-status] { box-shadow: none; background: var(--cond-tint); border-right-color: transparent; border-bottom-color: transparent; }
144
+ button.lnum[data-status="covered"] { color: var(--ok); border-top-color: var(--ok); border-left-color: var(--ok); }
145
+ button.lnum[data-status="gaps"] { color: var(--bad); border-top-color: var(--bad); border-left-color: var(--bad); }
146
+ button.lnum:hover { filter: brightness(1.1); }
147
+ button.lnum:focus-visible { outline: 3px solid var(--link); outline-offset: 1px; }
148
+ .tree {
149
+ margin: 4px 20px 12px 120px; padding: 10px 16px; border: 1px solid var(--tree-rule);
150
+ border-radius: 8px; background: var(--tree-bg); color: var(--tree-fg); line-height: 30px;
151
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
152
+ }
153
+ /* A row is its branch and label, then one fixed-width column per
154
+ path: flowing by column, each cell after the label takes the
155
+ next path's column, however many paths the site has. */
156
+ .row {
157
+ display: grid; grid-template-columns: auto minmax(200px, 1fr);
158
+ grid-auto-flow: column; grid-auto-columns: 32px; column-gap: 16px;
159
+ }
160
+ .operator { grid-column: span 2; font-weight: 600; }
161
+ .row[data-subtree] .operator { grid-column: auto; }
162
+ .row .arm { white-space: pre; font-weight: 600; }
163
+ .branch { white-space: pre; color: var(--tree-muted); margin-right: -16px; }
164
+ .row .text { white-space: pre; }
165
+ /* A branch glyph drawn as a cell a whole row tall: v a vertical
166
+ line, t a vertical with a stub right, l the top half of a
167
+ vertical with a stub right, h a horizontal, s nothing. */
168
+ .bx { display: inline-block; width: 1ch; height: 30px; vertical-align: top; --stroke: linear-gradient(currentColor, currentColor); }
169
+ .bx[data-bx="v"] { background: var(--stroke) 50% 0 / 1px 100% no-repeat; }
170
+ .bx[data-bx="t"] { background: var(--stroke) 50% 0 / 1px 100% no-repeat, var(--stroke) 100% 50% / 50% 1px no-repeat; }
171
+ .bx[data-bx="l"] { background: var(--stroke) 50% 0 / 1px 50% no-repeat, var(--stroke) 100% 50% / 50% 1px no-repeat; }
172
+ .bx[data-bx="h"] { background: var(--stroke) 0 50% / 100% 1px no-repeat; }
173
+ /* Each path's count heads its column, orange for a path never
174
+ taken; a leaf's T or F on that path sits in a chip, as in a
175
+ path grid, and a blank means the path never reached it. */
176
+ .row .count { text-align: center; font-variant-numeric: tabular-nums; color: var(--ok); font-weight: 600; }
177
+ .row .count.never { color: var(--tree-gap); }
178
+ .row .cell { text-align: center; }
179
+ .row .cell:not(:empty) {
180
+ align-self: center; line-height: 22px; border-radius: 3px;
181
+ background: var(--surface); color: var(--fg); font: 600 12px var(--font-code);
182
+ }
183
+
184
+ /* SPIKE: the code and a rail share the box; each tree sits in
185
+ the rail, level with its site (its top set by side.js). */
186
+ body[data-layout="beside"] .page { max-width: 1800px; }
187
+ /* The code sits between two rails, condition trees on the left
188
+ and path grids on the right, each as wide as --cc-rail or
189
+ --pc-rail. Both are 0 until one opens; registered, so they
190
+ animate, sliding the code and its frame aside together. */
191
+ @property --cc-rail { syntax: "<length>"; inherits: true; initial-value: 0px; }
192
+ @property --pc-rail { syntax: "<length>"; inherits: true; initial-value: 0px; }
193
+ body[data-layout="beside"] { transition: --cc-rail 250ms ease, --pc-rail 250ms ease; }
194
+ body[data-open="cc"] { --cc-rail: 560px; }
195
+ body[data-open="pc"] { --pc-rail: 560px; }
196
+ body[data-layout="beside"] .box:has(> .code) {
197
+ display: grid; column-gap: 12px;
198
+ grid-template-columns: var(--cc-rail) minmax(0, 1fr) var(--pc-rail);
199
+ }
200
+ /* Every row is as wide as the longest line (or the pane, if
201
+ wider), so a site's band and outline, and a lit line, reach
202
+ past the code when it scrolls sideways. */
203
+ body[data-layout="beside"] .code {
204
+ min-width: 0; overflow-x: auto;
205
+ display: grid; grid-template-columns: minmax(max-content, 1fr);
206
+ }
207
+ body[data-layout="beside"] .box:has(> .code)::before {
208
+ left: calc(var(--cc-rail) + 38px); right: calc(var(--pc-rail) + 12px);
209
+ }
210
+ .rail { position: relative; min-width: 0; }
211
+ /* The rail's tree sits on the condition tint, in the page's own
212
+ text colours, as a path grid sits on the path tint. */
213
+ .rail .tree {
214
+ --tree-bg: var(--cond-tint); --tree-fg: var(--fg); --tree-muted: var(--muted);
215
+ --tree-rule: var(--rule); --tree-gap: var(--bad);
216
+ }
217
+ /* The rail's tree has narrower columns, to fit its width; a
218
+ thin rule under each site's counts row, across the tree's
219
+ full width through its 16px side padding. */
220
+ .rail .row { grid-template-columns: auto minmax(120px, 1fr); grid-auto-columns: 26px; column-gap: 8px; }
221
+ .rail .site > .row:first-child {
222
+ border-bottom: 1px solid var(--tree-rule); margin: 0 -16px 6px; padding: 0 16px 4px;
223
+ }
224
+ .rail .tree { position: absolute; left: 0; right: 0; margin: 0; z-index: 2; font-family: var(--font-code); font-size: 13px; }
225
+ /* Beside, a site's whole block, its lines' code included, is
226
+ tinted by status and opens its tree, so each tree lines up
227
+ with a band the eye can follow across. */
228
+ :root { --bad-tint: #FBE6D6; }
229
+ @media (prefers-color-scheme: dark) { :root { --bad-tint: #3A2416; } }
230
+ /* Everything a site's grid draws has this tint, as everything
231
+ a method's grid draws has --path-tint; status is only ever
232
+ the orange or blue of an edge. */
233
+ :root { --cond-tint: #E2F2D3; }
234
+ @media (prefers-color-scheme: dark) { :root { --cond-tint: #26361A; } }
235
+ body[data-layout="beside"] .block { cursor: pointer; border-radius: 6px; }
236
+ /* A site's own characters are tinted, not its whole lines: the
237
+ expression is what its grid is about. */
238
+ /* The padding stretches the tint to the full 32px line, as tall
239
+ as a method's band, without moving the text. */
240
+ mark.site { color: inherit; border-radius: 3px; padding: 8px 0; }
241
+ mark.site { background: var(--cond-tint); }
242
+ mark.site[data-status="gaps"] { box-shadow: inset 0 -1px 0 var(--bad); }
243
+ mark.site[data-status="covered"] { box-shadow: inset 0 -1px 0 var(--ok); }
244
+ /* An open site, like an open method's bar, has its status edges
245
+ doubled to 2px: its button's top and left, and its
246
+ expressions' underlines. */
247
+ body[data-layout="beside"] button.lnum[aria-expanded="true"][data-status="gaps"] { box-shadow: inset 1px 1px 0 var(--bad); }
248
+ body[data-layout="beside"] button.lnum[aria-expanded="true"][data-status="covered"] { box-shadow: inset 1px 1px 0 var(--ok); }
249
+ body[data-layout="beside"] .block:has(> button.lnum[aria-expanded="true"]) mark.site[data-status="gaps"] { box-shadow: inset 0 -2px 0 var(--bad); }
250
+ body[data-layout="beside"] .block:has(> button.lnum[aria-expanded="true"]) mark.site[data-status="covered"] { box-shadow: inset 0 -2px 0 var(--ok); }
251
+ /* Beside, a jump to a site opens its tree, whose doubled edges
252
+ mark it, so its button has no outline of the jump to it; and
253
+ while a method's paths are open, none keeps a focus outline. */
254
+ body[data-layout="beside"] button.lnum:has(.lineno:target) { outline: none; }
255
+ body[data-open="pc"] button.lnum:focus-visible { outline: none; }
256
+ body[data-layout="beside"] .block .srcs { flex: 1; }
257
+ body[data-layout="beside"] .block:hover { filter: brightness(0.97); }
258
+ /* Beside, an open site's triangle points left, showing that a
259
+ click closes its tree, rather than down. */
260
+ /* A site's tree opens in the left rail: a closed button points
261
+ left, where its tree will open, an open one right, the way it
262
+ will close. */
263
+ body[data-layout="beside"] button.lnum::before { content: "\25C2"; transform: none; }
264
+ body[data-layout="beside"] button.lnum[aria-expanded="true"]::before { content: "\25B8"; transform: none; }
265
+
266
+ /* SPIKE: a path method's bar on the code pane's right edge,
267
+ def to end, which opens its grid out into the rail: a column
268
+ per path, never-taken first, each row level with its line. */
269
+ .pathbar {
270
+ position: absolute; width: 16px; padding: 0; z-index: 3;
271
+ border: 0; border-radius: 4px; cursor: pointer;
272
+ display: flex; align-items: center; justify-content: center;
273
+ color: var(--on-status); font-size: 11px;
274
+ }
275
+ .pathbar::after { content: "\25B8"; }
276
+ .pathbar[aria-expanded="true"]::after { content: "\25C2"; }
277
+ /* A method's def line, lit from its def to its bar, under the
278
+ code's text; the code is its own stacking context so the band
279
+ can sit beneath its rows. */
280
+ body[data-layout="beside"] .code { position: relative; isolation: isolate; }
281
+ .methodband { position: absolute; z-index: -1; border-radius: 6px 0 0 6px; pointer-events: none; }
282
+ /* A method's band is violet, whatever its status, so it never
283
+ reads as a site's band; its status is its bar's colour and its
284
+ bottom line's. */
285
+ :root { --path-tint: #E9E0FB; }
286
+ @media (prefers-color-scheme: dark) { :root { --path-tint: #33284F; } }
287
+ .methodband { background: var(--path-tint); }
288
+ .methodband[data-status="gaps"] { box-shadow: inset 0 1px 0 var(--bad); }
289
+ .methodband[data-status="covered"] { box-shadow: inset 0 1px 0 var(--ok); }
290
+ .line:has(.lineno) { cursor: default; }
291
+ /* A bar is violet like its method's band, so violet always means
292
+ paths; its status is a line down its right edge, as the band's
293
+ is a line along its top. */
294
+ .pathbar {
295
+ background: var(--path-tint); color: var(--fg);
296
+ border-radius: 0 0 4px 4px; align-items: flex-start; line-height: 32px;
297
+ }
298
+ .pathbar[data-status="gaps"] { box-shadow: inset -1px 0 0 var(--bad), inset 0 1px 0 var(--bad); }
299
+ .pathbar[data-status="covered"] { box-shadow: inset -1px 0 0 var(--ok), inset 0 1px 0 var(--ok); }
300
+ /* Open, a method's status lines thicken, on its bar and band. */
301
+ .pathbar[aria-expanded="true"][data-status="gaps"] { box-shadow: inset -2px 0 0 var(--bad), inset 0 2px 0 var(--bad); }
302
+ .pathbar[aria-expanded="true"][data-status="covered"] { box-shadow: inset -2px 0 0 var(--ok), inset 0 2px 0 var(--ok); }
303
+ .methodband.open[data-status="gaps"] { box-shadow: inset 0 2px 0 var(--bad); }
304
+ .methodband.open[data-status="covered"] { box-shadow: inset 0 2px 0 var(--ok); }
305
+ .pathbar:hover { filter: brightness(1.15); }
306
+ .rail .pathgrid {
307
+ position: absolute; left: 0; right: 0; z-index: 2; overflow-x: auto; overflow-y: hidden;
308
+ background: var(--path-tint); border-radius: 6px; font: 600 12px var(--font-code);
309
+ }
310
+ .prow { position: absolute; left: 0; display: flex; gap: 2px; white-space: nowrap; }
311
+ .plabel { width: 48px; flex-shrink: 0; text-align: right; padding-right: 8px; color: var(--muted); line-height: 24px; }
312
+ .pcell { width: 24px; flex-shrink: 0; text-align: center; line-height: 24px; border-radius: 3px; }
313
+ .pcell.ended { background: transparent; }
314
+ .pcell.true, .pcell.false { background: var(--surface); color: var(--fg); }
315
+ .prow.calls .pcell.taken { background: var(--ok-tint); color: var(--ok); }
316
+ .prow.calls .pcell.never { background: var(--bad); color: var(--on-status); }
317
+ .prow.calls .plabel { color: var(--fg); }
318
+ /* SPIKE: hovering a path's column shows it in the code: the
319
+ column outlined, skipped lines faded, each decision's line
320
+ chipped with its outcome. */
321
+ .pcell { cursor: default; }
322
+ .pcell { cursor: pointer; }
323
+ .pcell.hot { outline: 2px solid var(--fg); outline-offset: -1px; }
324
+ .pcell.pinned { outline: 3px solid var(--link); outline-offset: -2px; }
325
+ :root { --lit: #FFE98A; }
326
+ @media (prefers-color-scheme: dark) { :root { --lit: #5A4A12; } }
327
+ /* A lit line's background spans the code pane's full width. */
328
+ .line .src { flex: 1; }
329
+ .src.lit { background: var(--path-tint); }
330
+ mark.chars { background: var(--lit); color: inherit; border-radius: 3px; box-shadow: 0 0 0 2px var(--lit); }
331
+ .tree .row[data-at] { cursor: default; }
332
+ .tree .row[data-at]:hover { background: rgba(255, 255, 255, 0.08); }
333
+ .nav[data-chip] { flex-direction: row; align-items: center; justify-content: flex-end; padding-right: 2px; box-sizing: border-box; }
334
+ .nav[data-chip] a { display: none; }
335
+ .nav[data-chip]::after {
336
+ content: attr(data-chip); padding: 0 7px; border-radius: 4px;
337
+ font: 700 11px/20px var(--font-body); background: var(--fg); color: var(--bg);
338
+ }
@@ -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
+ })();
@@ -0,0 +1,109 @@
1
+ require 'cgi/escape'
2
+ require_relative 'html_nav'
3
+ require_relative 'html_template'
4
+ require_relative 'html_tree'
5
+
6
+ module PathCoverage
7
+ # One covered file's HTML page: its source, with the numbers
8
+ # of the lines holding a condition as the one button that
9
+ # opens the condition's tree.
10
+ module HtmlFile
11
+ # The whole page for the file at path (relative to root),
12
+ # written at files/<path>.html, so it links back to the
13
+ # index one folder up for each folder in that.
14
+ def self.page(path, lines, trees, grids = '')
15
+ HtmlTemplate.fill(
16
+ HtmlTemplate.asset('file.html'),
17
+ 'path' => CGI.escapeHTML(path),
18
+ 'index' => "#{'../' * (path.count('/') + 1)}index.html",
19
+ 'style' => HtmlTemplate.asset('style.css'),
20
+ 'script' => script,
21
+ 'grids' => grids,
22
+ 'jumps' => HtmlNav.jumps(lines),
23
+ 'listing' => listing(lines, trees)
24
+ )
25
+ end
26
+
27
+ # The page's script: its tree toggles, then its side rails.
28
+ def self.script
29
+ HtmlTemplate.asset('toggle.js') + HtmlTemplate.asset('side.js')
30
+ end
31
+
32
+ # The source listing of lines, as SourceLines.of gives them:
33
+ # each run of lines sharing a span is one block, followed
34
+ # by its hidden tree. trees is { span's first line =>
35
+ # [SiteTree.of, ...] }.
36
+ def self.listing(lines, trees = {})
37
+ blocks = lines.chunk_while do |line, following|
38
+ line[:span] && line[:span] == following[:span]
39
+ end
40
+ gaps = HtmlNav.gap_lines(blocks)
41
+ body = blocks.map { |block| block(block, trees, gaps) }
42
+ %(<div class="code">\n#{body.join("\n")}\n</div>)
43
+ end
44
+
45
+ # A plain line, or the lines of one span behind one button
46
+ # with the span's tree straight after them; either way after
47
+ # its nav cell.
48
+ def self.block(lines, trees, gaps)
49
+ first = lines[0]
50
+ number = first[:number]
51
+ cell = HtmlNav.cell(number, gaps)
52
+ return plain_line(first, cell) if first[:span].nil?
53
+
54
+ site_trees = trees.fetch(number, [])
55
+ open = first[:status] == :gaps
56
+ span_block(lines, site_trees.size, cell) +
57
+ HtmlTree.of(number, site_trees, open)
58
+ end
59
+
60
+ # The lines of one span after their nav cell: one button over
61
+ # all their numbers, and how many sites it opens when that is
62
+ # two or more, beside their code.
63
+ def self.span_block(lines, sites, cell)
64
+ numbers = lines.map { |line| number(line[:number]) }.join
65
+ codes = lines.map { |line| code(line) }.join
66
+ srcs = %(<div class="srcs">#{codes}</div>)
67
+ content = site_count(sites) + numbers
68
+ button = button(lines[0], content)
69
+ %(<div class="block">#{cell}#{button}#{srcs}</div>)
70
+ end
71
+
72
+ # The count of a button's sites, shown only for two or more.
73
+ def self.site_count(sites)
74
+ return '' if sites < 2
75
+
76
+ %(<span class="site-count" data-site-count="">#{sites}</span>)
77
+ end
78
+
79
+ # A line with no condition: its nav cell, number and code.
80
+ def self.plain_line(line, cell)
81
+ parts = cell + number(line[:number]) + code(line)
82
+ %(<div class="line">#{parts}</div>)
83
+ end
84
+
85
+ # The button over a span's numbers, toggling the tree named
86
+ # after its first line; expanded when that tree starts open.
87
+ def self.button(first, numbers)
88
+ expanded = first[:status] == :gaps
89
+ [
90
+ %(<button type="button" class="lnum"),
91
+ %( data-status="#{first[:status]}"),
92
+ %( aria-expanded="#{expanded}"),
93
+ %( aria-controls="tree-#{first[:number]}">#{numbers}</button>)
94
+ ].join
95
+ end
96
+
97
+ # One line's number, the target of a link to #L<number>.
98
+ def self.number(number)
99
+ attributes = %(id="L#{number}" data-line="#{number}")
100
+ %(<span class="lineno" #{attributes}>#{number}</span>)
101
+ end
102
+
103
+ # One line's code, escaped.
104
+ def self.code(line)
105
+ text = CGI.escapeHTML(line[:text])
106
+ %(<span class="src" data-src="">#{text}</span>)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,74 @@
1
+ require 'cgi/escape'
2
+ require_relative 'html_template'
3
+
4
+ module PathCoverage
5
+ # The HTML index page: one sortable row per covered file,
6
+ # each linking to that file's own page, then a Total row.
7
+ 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)
12
+ HtmlTemplate.fill(
13
+ HtmlTemplate.asset('index.html'),
14
+ '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 : ''
19
+ )
20
+ end
21
+
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
27
+
28
+ # The table row of one Summary.files row; each cell has a
29
+ # data-sort value for the page's column sorting.
30
+ def self.row(row)
31
+ path = CGI.escapeHTML(row[:path])
32
+ link = %(<a href="files/#{path}.html">#{path}</a>)
33
+ "<tr>#{cell(path, link)}#{counts(row).join}</tr>"
34
+ end
35
+
36
+ # The footer row: the sums over every file.
37
+ def self.total_row(total)
38
+ "<tr>#{cell('Total', 'Total')}#{counts(total).join}</tr>"
39
+ end
40
+
41
+ # The cells after the file's: its sites, then its total
42
+ # within each decision, then, when methods are measured, its
43
+ # total across a method's decisions.
44
+ def self.counts(row)
45
+ cells = [
46
+ cell(row[:sites], row[:sites]),
47
+ paths_cell(row[:decisions])
48
+ ]
49
+ row[:methods] ? cells + [paths_cell(row[:methods])] : cells
50
+ end
51
+
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)
58
+ taken, possible = pair
59
+ if possible.zero?
60
+ return cell('', '-', 'no method with two or more decisions')
61
+ end
62
+
63
+ percent = (100.0 * taken / possible).round
64
+ cell(percent, "#{taken} / #{possible} (#{percent}%)")
65
+ end
66
+
67
+ # 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)
70
+ hover = title ? %( title="#{title}") : ''
71
+ %(<td data-sort="#{sort_value}"#{hover}>#{content}</td>)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ module PathCoverage
2
+ # The ways around a file's page: the header's lists of links to
3
+ # each span, and the arrows between the spans with gaps.
4
+ module HtmlNav
5
+ JUMP_LISTS = { 'covered' => 'Covered', 'gaps' => 'Gaps' }.freeze
6
+
7
+ # The header's lists of links to the first line of each span:
8
+ # those covered, then those with gaps.
9
+ def self.jumps(lines)
10
+ firsts = lines.select { |line| first_of_span?(line) }
11
+ lists = JUMP_LISTS.map do |status, label|
12
+ spans = firsts.select { |line| line[:status].to_s == status }
13
+ jump_list(status, label, spans.map { |line| line[:number] })
14
+ end
15
+ %(<nav class="jumps" aria-label="Sites">#{lists.join}</nav>)
16
+ end
17
+
18
+ # True for the line a span starts on.
19
+ def self.first_of_span?(line)
20
+ line[:span]&.begin == line[:number]
21
+ end
22
+
23
+ # One labelled list of links to lines.
24
+ def self.jump_list(status, label, numbers)
25
+ links = numbers.map { |number| jump(status, number) }
26
+ name = %(<span class="jump-label">#{label}</span>)
27
+ list = %(<div class="jump-list" data-jumps="#{status}">)
28
+ "#{list}#{name}#{links.join}</div>"
29
+ end
30
+
31
+ # One link to a line.
32
+ def self.jump(status, number)
33
+ attributes = %(data-status="#{status}" href="#L#{number}")
34
+ %(<a class="jump" #{attributes}>#{number}</a>)
35
+ end
36
+
37
+ # The first line of each span whose sites have gaps, given the
38
+ # listing's blocks.
39
+ def self.gap_lines(blocks)
40
+ firsts = blocks.map(&:first)
41
+ firsts.select { |line| line[:status] == :gaps }.map do |line|
42
+ line[:number]
43
+ end
44
+ end
45
+
46
+ # The cell left of a row's number: at a span with gaps, links
47
+ # to the previous and next such span, wrapping round at the
48
+ # ends; empty on every other row, so the numbers stay aligned.
49
+ def self.cell(number, gaps)
50
+ index = gaps.index(number)
51
+ links = index ? arrows(gaps, index) : ''
52
+ %(<span class="nav" data-nav="">#{links}</span>)
53
+ end
54
+
55
+ # The links to the spans with gaps either side of gaps[index].
56
+ def self.arrows(gaps, index)
57
+ previous = gaps[index - 1]
58
+ following = gaps[(index + 1) % gaps.size]
59
+ [
60
+ %(<a data-prev="" href="#L#{previous}"),
61
+ %( aria-label="Previous gap, line #{previous}">&#9650;</a>),
62
+ %(<a data-next="" href="#L#{following}"),
63
+ %( aria-label="Next gap, line #{following}">&#9660;</a>)
64
+ ].join
65
+ end
66
+ end
67
+ end