path_coverage 0.0.1 → 0.0.2

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