Skip to main content

bynk_driver/
coverage.rs

1//! `bynkc test --coverage` — remap V8 line coverage onto `.bynk` source.
2//!
3//! The test runner already owns the two artefacts a coverage tool needs and a
4//! user cannot reconstruct: it launches the `node` process that executes the
5//! suite, and it holds the source maps from `.bynk` → emitted `.ts`. This module
6//! is the "one genuinely new piece" the coverage proposal (issue #854, ADR
7//! recorded at merge) calls out: it reads the raw V8 coverage the runtime writes
8//! to `NODE_V8_COVERAGE`, and attributes each executed / unexecuted line back to
9//! `.bynk` source through **two** line-level source-map hops:
10//!
11//! 1. `out-js/**/*.js.map` — tsc's map, `.js` line → emitted `.ts` line. `tsc`
12//!    does **not** chain input maps, so this hop only reaches the `.ts`.
13//! 2. `out/**/*.ts.map` — the emitter's map (ADR 0103), `.ts` line → `.bynk`
14//!    line. Statement-anchored and line-level (generated column always 0).
15//!
16//! Composed, a covered `.js` line lands on a `.bynk` line. Emitted glue with no
17//! `.bynk` origin (codec wrappers, capability injection, the module header) is
18//! **unmapped** in hop 2, so it contributes nothing — it is counted as
19//! out-of-scope, never as uncovered user code (the proposal's map-fidelity
20//! mitigation, for free).
21//!
22//! **Decisions realised here** (recorded in the ADR): line/statement coverage
23//! only, no branch coverage (DECISION B) — a `.bynk` line is *covered* if any
24//! generated line mapping to it executed; and the measured set excludes the
25//! `tests/` tree and the workers scaffold (DECISION D), filtered once on the
26//! `out-js`-relative path of the executed `.js` — the emitted tree's own
27//! top-level `tests/`/`workers/` dirs — before the maps are even consulted. The
28//! `.bynk` side is deliberately *not* re-filtered, so a user source that merely
29//! lives under a dir named `tests`/`workers` is still measured.
30
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Component, Path, PathBuf};
33
34use serde::Deserialize;
35
36/// Per-`.bynk`-file line coverage, keyed by a project-relative display path.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FileCoverage {
39    /// Project-relative `.bynk` path (forward-slashed), e.g. `src/limiter.bynk`.
40    pub path: String,
41    /// Covered executable lines (1-based count).
42    pub covered: u32,
43    /// Total executable lines attributed to this file (1-based count).
44    pub total: u32,
45    /// The uncovered executable lines, 1-based and ascending — the exact set the
46    /// proposal's fixtures pin.
47    pub uncovered: Vec<u32>,
48}
49
50/// A whole-run coverage report: one [`FileCoverage`] per measured `.bynk` file,
51/// sorted by path, plus the derived totals.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct CoverageReport {
54    pub files: Vec<FileCoverage>,
55}
56
57impl CoverageReport {
58    /// Total covered executable lines across every measured file.
59    pub fn total_covered(&self) -> u32 {
60        self.files.iter().map(|f| f.covered).sum()
61    }
62
63    /// Total executable lines across every measured file.
64    pub fn total_lines(&self) -> u32 {
65        self.files.iter().map(|f| f.total).sum()
66    }
67
68    /// Whole-run percentage (0–100), rounded to the nearest integer. A run that
69    /// attributed no executable line is reported as 100% (nothing to cover).
70    pub fn total_percent(&self) -> u32 {
71        percent(self.total_covered(), self.total_lines())
72    }
73
74    /// Whether the report attributed no `.bynk` line at all — an empty measured
75    /// set (e.g. an integration-only project whose only executed code is the
76    /// workers scaffold DECISION D drops).
77    pub fn is_empty(&self) -> bool {
78        self.files.is_empty()
79    }
80}
81
82/// Coverage percentage of `covered`/`total`, rounded; `total == 0` → 100. Only a
83/// genuinely complete run reads 100%: round-half-up would report `995/1000` as
84/// `100%` while lines are still uncovered — self-contradicting in the table and
85/// a false green for a CI gate keyed on the JSON `percent` — so a run with any
86/// uncovered line is clamped to at most 99.
87pub fn percent(covered: u32, total: u32) -> u32 {
88    if total == 0 || covered >= total {
89        100
90    } else {
91        let rounded = (covered as u64 * 100 + total as u64 / 2) / total as u64;
92        (rounded as u32).min(99)
93    }
94}
95
96// -- V8 coverage JSON (the `NODE_V8_COVERAGE` output shape) --
97
98#[derive(Deserialize)]
99struct V8Document {
100    #[serde(default)]
101    result: Vec<V8Script>,
102}
103
104#[derive(Deserialize)]
105struct V8Script {
106    url: String,
107    #[serde(default)]
108    functions: Vec<V8Function>,
109}
110
111#[derive(Deserialize)]
112struct V8Function {
113    #[serde(default)]
114    ranges: Vec<V8Range>,
115}
116
117#[derive(Deserialize)]
118struct V8Range {
119    #[serde(rename = "startOffset")]
120    start: usize,
121    #[serde(rename = "endOffset")]
122    end: usize,
123    count: i64,
124}
125
126/// Collect coverage from a finished run and attribute it to `.bynk` source.
127///
128/// - `v8_dir` — the directory `NODE_V8_COVERAGE` was pointed at.
129/// - `out_js_root` — where the executed `.js` and tsc's `.js.map` live.
130/// - `out_root` — where the emitted `.ts` and the emitter's `.ts.map` live.
131/// - `source_root` — the project root the `.bynk` paths are relativised against.
132///
133/// Any file it cannot read or parse is skipped rather than failing the run —
134/// coverage is a report *about* a run that already happened, so a partial map
135/// should degrade the numbers, never abort. Reading the V8 directory is the one
136/// hard error surfaced (it is the runner's own temp dir).
137pub fn collect_coverage(
138    v8_dir: &Path,
139    out_js_root: &Path,
140    out_root: &Path,
141    source_root: &Path,
142) -> std::io::Result<CoverageReport> {
143    // 1. Fold every V8 document into per-script merged ranges. A single-process
144    //    run writes one file, but Node may split across several; merging the
145    //    ranges (and taking the innermost at lookup time) is correct either way.
146    let mut scripts: HashMap<PathBuf, Vec<V8Range>> = HashMap::new();
147    for entry in std::fs::read_dir(v8_dir)? {
148        let path = entry?.path();
149        if path.extension().and_then(|e| e.to_str()) != Some("json") {
150            continue;
151        }
152        let Ok(text) = std::fs::read_to_string(&path) else {
153            continue;
154        };
155        let Ok(doc) = serde_json::from_str::<V8Document>(&text) else {
156            continue;
157        };
158        for script in doc.result {
159            let Some(fs_path) = file_url_to_path(&script.url) else {
160                continue;
161            };
162            let canon = std::fs::canonicalize(&fs_path).unwrap_or(fs_path);
163            let ranges = scripts.entry(canon).or_default();
164            for func in script.functions {
165                ranges.extend(func.ranges);
166            }
167        }
168    }
169
170    let out_js_canon =
171        std::fs::canonicalize(out_js_root).unwrap_or_else(|_| out_js_root.to_path_buf());
172
173    // Accumulate per `.bynk` file: the executable lines seen, and which executed.
174    let mut acc: BTreeMap<String, FileAcc> = BTreeMap::new();
175
176    for (script_path, ranges) in &scripts {
177        let Ok(rel) = script_path.strip_prefix(&out_js_canon) else {
178            continue;
179        };
180        // DECISION D: drop the workers scaffold, the emitted test modules, and
181        // the runtime — before the maps are consulted. These are the emitted
182        // `out-js` paths, so the filter is on the generated tree, not `.bynk`.
183        if !is_measurable_emitted(rel) {
184            continue;
185        }
186        let Ok(js_text) = std::fs::read_to_string(script_path) else {
187            continue;
188        };
189        // Hop 1: tsc's `.js.map` sits beside the `.js`.
190        let js_map_path = append_ext(script_path, "map");
191        let Some(js_map) = std::fs::read_to_string(&js_map_path)
192            .ok()
193            .and_then(|s| SourceMap::parse(&s))
194        else {
195            continue;
196        };
197        // Hop 2: the emitter's `.ts.map`, mirrored under `out/` at the same rel
198        // path (tsc's `rootDir: "."`, `outDir: "../out-js"` keeps the tree 1:1).
199        let ts_rel = rel.with_extension("ts");
200        let ts_map_path = append_ext(&out_root.join(&ts_rel), "map");
201        let Some(ts_map) = std::fs::read_to_string(&ts_map_path)
202            .ok()
203            .and_then(|s| SourceMap::parse(&s))
204        else {
205            continue;
206        };
207
208        let line_reps = line_representatives(&js_text);
209        for (js_line, rep_off) in line_reps.iter().enumerate() {
210            let Some(off) = rep_off else { continue };
211            // The verdict for this generated line is the *tightest* V8 range
212            // covering it — see [`innermost_range`]. A generated line covered by
213            // no range (the trailing `sourceMappingURL` comments) attributes
214            // nothing.
215            let Some((span, count)) = innermost_range(*off, ranges) else {
216                continue;
217            };
218            let Some(js_segs) = js_map.lines.get(js_line) else {
219                continue;
220            };
221            for &(_js_src, ts_line) in js_segs {
222                let Some(ts_segs) = ts_map.lines.get(ts_line as usize) else {
223                    continue;
224                };
225                for &(bynk_src, bynk_line) in ts_segs {
226                    let Some(bynk_abs) = ts_map.sources.get(bynk_src) else {
227                        continue;
228                    };
229                    let disp = relativise(bynk_abs, source_root);
230                    // DECISION D is enforced once, authoritatively, on the emitted
231                    // tree by `is_measurable_emitted` (the `tests/` and `workers/`
232                    // top-level dirs of `out-js`). We deliberately do *not* re-filter
233                    // on the `.bynk` side: a user source that merely lives under a
234                    // dir named `tests`/`workers` (e.g. `src/workers/helpers.bynk`)
235                    // is real code whose `.js` already passed the emitted filter, so
236                    // dropping it here would silently omit it from coverage.
237                    let file = acc.entry(disp).or_default();
238                    // Lines are 1-based in every report the user sees. A `.bynk`
239                    // line's verdict is decided by the *most specific* (smallest-
240                    // span) generated range mapping to it: a function body's
241                    // range beats the whole-module range, so a hoisted
242                    // `exports.f = f;` (which runs at load and maps back to the
243                    // declaration line) never masks an uncalled function.
244                    let line1 = bynk_line + 1;
245                    file.observe(line1, span, count);
246                }
247            }
248        }
249    }
250
251    let files = acc
252        .into_iter()
253        .map(|(path, a)| {
254            let total = a.lines.len() as u32;
255            let mut covered = 0u32;
256            let mut uncovered = Vec::new();
257            for (&line, &(_, count)) in &a.lines {
258                if count > 0 {
259                    covered += 1;
260                } else {
261                    uncovered.push(line);
262                }
263            }
264            FileCoverage {
265                path,
266                covered,
267                total,
268                uncovered,
269            }
270        })
271        .collect();
272    Ok(CoverageReport { files })
273}
274
275#[derive(Default)]
276struct FileAcc {
277    /// Per 1-based `.bynk` line: the tightest generated range's `(span, count)`
278    /// observed for it. `BTreeMap` keeps the uncovered list ascending for free.
279    lines: BTreeMap<u32, (usize, i64)>,
280}
281
282impl FileAcc {
283    /// Record that a generated position inside a range of `span`/`count` maps to
284    /// `line`. The tightest span wins; on a span tie the larger count wins (a
285    /// position two coverage files agree ran is covered).
286    fn observe(&mut self, line: u32, span: usize, count: i64) {
287        let slot = self.lines.entry(line).or_insert((usize::MAX, 0));
288        if span < slot.0 || (span == slot.0 && count > slot.1) {
289            *slot = (span, count);
290        }
291    }
292}
293
294/// The **innermost** (smallest-span) V8 range containing `off`, as `(span,
295/// count)`. V8 block coverage nests a not-taken block's `count: 0` range inside
296/// its enclosing function's `count: N` range, so the smallest containing range
297/// is the precise verdict for that position. `None` if no range contains `off`.
298fn innermost_range(off: usize, ranges: &[V8Range]) -> Option<(usize, i64)> {
299    let mut best: Option<(usize, i64)> = None;
300    for r in ranges {
301        if r.start <= off && off < r.end {
302            let span = r.end - r.start;
303            match best {
304                Some((bs, bc)) if span > bs || (span == bs && r.count <= bc) => {}
305                _ => best = Some((span, r.count)),
306            }
307        }
308    }
309    best
310}
311
312/// For each generated line, the **UTF-16 offset** of its first non-whitespace
313/// char — the position sampled against the V8 ranges. `None` for a blank line
314/// (nothing to attribute; such lines carry no mapping anyway).
315///
316/// V8 coverage `startOffset`/`endOffset` index the source as a JS string, i.e.
317/// in UTF-16 code units, not UTF-8 bytes (the same space `v8-to-istanbul`/`c8`
318/// use). For ASCII-only output the two coincide, but a single non-ASCII char
319/// earlier in the file (a Unicode `.bynk` string literal carried into the `.js`)
320/// shifts every later byte offset relative to V8's counting, which would select
321/// the wrong range. So offsets are accumulated in UTF-16 units to match.
322fn line_representatives(text: &str) -> Vec<Option<usize>> {
323    let mut out = Vec::new();
324    let mut u16_off = 0usize;
325    for line in text.split_inclusive('\n') {
326        let mut rep = None;
327        let mut o = u16_off;
328        for c in line.chars() {
329            if c.is_whitespace() {
330                o += c.len_utf16();
331            } else {
332                rep = Some(o);
333                break;
334            }
335        }
336        out.push(rep);
337        u16_off += line.chars().map(char::len_utf16).sum::<usize>();
338    }
339    out
340}
341
342/// Whether an emitted `out-js`-relative path is a file we measure: not the
343/// `tests/` tree, not the `workers/` scaffold, not the shared runtime, and a
344/// `.js` module (DECISION D).
345fn is_measurable_emitted(rel: &Path) -> bool {
346    if rel.extension().and_then(|e| e.to_str()) != Some("js") {
347        return false;
348    }
349    let mut comps = rel.components();
350    match comps.next() {
351        Some(Component::Normal(c)) if c == "tests" || c == "workers" => return false,
352        _ => {}
353    }
354    // The runtime helpers are framework code, never user source.
355    if rel == Path::new("runtime.js") {
356        return false;
357    }
358    true
359}
360
361/// Relativise an absolute `.bynk` source path against the project root, forward-
362/// slashed. Falls back to the file name, then the path verbatim, so an
363/// out-of-tree source (a synthetic unit) still renders something legible.
364fn relativise(abs: &str, source_root: &Path) -> String {
365    let p = Path::new(abs);
366    if let Ok(rel) = p.strip_prefix(source_root) {
367        return forward_slash(rel);
368    }
369    // The map source is stored forward-slashed absolute; the root may be a
370    // different textual form of the same dir. Fall back to the file name.
371    p.file_name()
372        .map(|n| n.to_string_lossy().into_owned())
373        .unwrap_or_else(|| abs.to_string())
374}
375
376fn forward_slash(p: &Path) -> String {
377    p.components()
378        .filter_map(|c| match c {
379            Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
380            _ => None,
381        })
382        .collect::<Vec<_>>()
383        .join("/")
384}
385
386/// Convert a `file://` URL to a filesystem path, minimally percent-decoding.
387/// Returns `None` for a non-`file:` URL (e.g. `node:internal/...`).
388fn file_url_to_path(url: &str) -> Option<PathBuf> {
389    let rest = url.strip_prefix("file://")?;
390    // `file:///abs` → `/abs`; a host part is not expected for local coverage.
391    let path = rest
392        .strip_prefix('/')
393        .map(|r| format!("/{r}"))
394        .unwrap_or_else(|| rest.to_string());
395    Some(PathBuf::from(percent_decode(&path)))
396}
397
398/// Minimal `%XX` percent-decoding — enough for paths with spaces in a temp dir.
399fn percent_decode(s: &str) -> String {
400    let bytes = s.as_bytes();
401    let mut out = Vec::with_capacity(bytes.len());
402    let mut i = 0;
403    while i < bytes.len() {
404        if bytes[i] == b'%' && i + 2 < bytes.len() {
405            let hi = (bytes[i + 1] as char).to_digit(16);
406            let lo = (bytes[i + 2] as char).to_digit(16);
407            if let (Some(h), Some(l)) = (hi, lo) {
408                out.push((h * 16 + l) as u8);
409                i += 3;
410                continue;
411            }
412        }
413        out.push(bytes[i]);
414        i += 1;
415    }
416    String::from_utf8_lossy(&out).into_owned()
417}
418
419/// Append `.ext` to a path's file name (`foo.js` + `map` → `foo.js.map`), unlike
420/// [`Path::with_extension`] which would replace `js`.
421fn append_ext(path: &Path, ext: &str) -> PathBuf {
422    let mut name = path
423        .file_name()
424        .map(|n| n.to_os_string())
425        .unwrap_or_default();
426    name.push(".");
427    name.push(ext);
428    path.with_file_name(name)
429}
430
431// -- Source-map v3 (the subset needed for line attribution) --
432
433/// A decoded source-map: its `sources` list and, per generated line (0-based),
434/// the segments on that line as `(source_index, source_line_0based)`. Generated
435/// and source columns are decoded to keep the VLQ deltas honest, then dropped —
436/// attribution is line-level on both hops.
437struct SourceMap {
438    sources: Vec<String>,
439    lines: Vec<Vec<(usize, u32)>>,
440}
441
442impl SourceMap {
443    fn parse(json: &str) -> Option<SourceMap> {
444        #[derive(Deserialize)]
445        struct Raw {
446            #[serde(default)]
447            sources: Vec<String>,
448            #[serde(default)]
449            mappings: String,
450        }
451        let raw: Raw = serde_json::from_str(json).ok()?;
452        Some(SourceMap {
453            sources: raw.sources,
454            lines: decode_line_mappings(&raw.mappings),
455        })
456    }
457}
458
459/// Decode a v3 `mappings` string into, per generated line, its `(src_idx,
460/// src_line)` segments (both 0-based). Follows the VLQ delta rules: the source
461/// index/line/column deltas persist across segments *and* lines; the generated
462/// column resets at each line boundary. A one-field segment (generated column
463/// only, no source) carries no attribution and is skipped.
464fn decode_line_mappings(mappings: &str) -> Vec<Vec<(usize, u32)>> {
465    let mut lines = Vec::new();
466    // Source index and source line are independent running totals that persist
467    // across segments and lines (the source column would be a third, but line
468    // attribution never needs it). The generated column resets each line and is
469    // irrelevant here, so it is decoded but discarded.
470    let (mut src, mut src_line) = (0i64, 0i64);
471    for seg_line in mappings.split(';') {
472        let mut segs = Vec::new();
473        for seg in seg_line.split(',') {
474            if seg.is_empty() {
475                continue;
476            }
477            let fields = vlq_decode(seg);
478            if fields.len() >= 4 {
479                src += fields[1];
480                src_line += fields[2];
481                if src >= 0 && src_line >= 0 {
482                    segs.push((src as usize, src_line as u32));
483                }
484            }
485        }
486        lines.push(segs);
487    }
488    lines
489}
490
491/// Base64-VLQ-decode one segment into its signed fields.
492fn vlq_decode(seg: &str) -> Vec<i64> {
493    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
494    let mut out = Vec::new();
495    let (mut shift, mut acc) = (0u32, 0i64);
496    for &c in seg.as_bytes() {
497        let Some(d) = B64.iter().position(|&b| b == c).map(|p| p as i64) else {
498            continue;
499        };
500        acc += (d & 0b11111) << shift;
501        if d & 0b100000 != 0 {
502            shift += 5;
503        } else {
504            let value = if acc & 1 == 1 { -(acc >> 1) } else { acc >> 1 };
505            out.push(value);
506            shift = 0;
507            acc = 0;
508        }
509    }
510    out
511}
512
513// -- Rich rendering --
514
515/// Render the human coverage summary — a per-file bar, percentage, covered/total
516/// line count, and the uncovered-line ranges — plus a total row. Trailing
517/// newline. An empty report renders a single "no coverage attributed" note.
518pub fn render_rich(report: &CoverageReport) -> String {
519    let mut out = String::new();
520    out.push_str("\nCoverage\n");
521    if report.is_empty() {
522        out.push_str("  (no executable `.bynk` lines were attributed)\n");
523        return out;
524    }
525    let name_w = report
526        .files
527        .iter()
528        .map(|f| f.path.len())
529        .max()
530        .unwrap_or(0)
531        .max(5);
532    for f in &report.files {
533        let pct = percent(f.covered, f.total);
534        out.push_str(&format!(
535            "  {:<name_w$}  {}  {:>3}%   ({}/{} lines)",
536            f.path,
537            bar(f.covered, f.total),
538            pct,
539            f.covered,
540            f.total,
541        ));
542        if !f.uncovered.is_empty() {
543            out.push_str(&format!("  uncovered: {}", format_ranges(&f.uncovered)));
544        }
545        out.push('\n');
546    }
547    let dashes = "─".repeat(name_w + 24);
548    out.push_str(&format!("  {dashes}\n"));
549    out.push_str(&format!(
550        "  {:<name_w$}  {}  {:>3}%   ({}/{} lines)\n",
551        "total",
552        bar(report.total_covered(), report.total_lines()),
553        report.total_percent(),
554        report.total_covered(),
555        report.total_lines(),
556    ));
557    out
558}
559
560/// An 8-cell coverage bar: filled `▓` proportional to the covered fraction, the
561/// remainder `·`.
562fn bar(covered: u32, total: u32) -> String {
563    const CELLS: u32 = 8;
564    let filled = if total == 0 {
565        CELLS
566    } else {
567        (covered as u64 * CELLS as u64 / total as u64) as u32
568    };
569    let mut s = String::new();
570    for _ in 0..filled {
571        s.push('▓');
572    }
573    for _ in filled..CELLS {
574        s.push('·');
575    }
576    s
577}
578
579/// Compress a sorted line list into comma-separated ranges: `[6,7,8,51]` →
580/// `"6-8, 51"`.
581pub fn format_ranges(lines: &[u32]) -> String {
582    let mut parts = Vec::new();
583    let mut i = 0;
584    while i < lines.len() {
585        let start = lines[i];
586        let mut end = start;
587        while i + 1 < lines.len() && lines[i + 1] == end + 1 {
588            end += 1;
589            i += 1;
590        }
591        if start == end {
592            parts.push(format!("{start}"));
593        } else {
594            parts.push(format!("{start}-{end}"));
595        }
596        i += 1;
597    }
598    parts.join(", ")
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn vlq_decodes_signed_fields() {
607        // "AAAA" → [0,0,0,0]; "AAEA" → [0,0,2,0]; "AACE" → [0,0,1,2]; "D" → [-1].
608        assert_eq!(vlq_decode("AAAA"), vec![0, 0, 0, 0]);
609        assert_eq!(vlq_decode("AAEA"), vec![0, 0, 2, 0]);
610        assert_eq!(vlq_decode("AACE"), vec![0, 0, 1, 2]);
611        assert_eq!(vlq_decode("D"), vec![-1]);
612    }
613
614    #[test]
615    fn line_mappings_track_running_source_line() {
616        // The emitter's `.ts.map` for the two-fn spike: 5 blank lines, then
617        // src lines 2,3,3,6,7,8 (0-based) — running deltas across lines.
618        let m = ";;;;;AAEA;AACE;AAAA;AAGF;AACE;AACA";
619        let lines = decode_line_mappings(m);
620        assert_eq!(lines[0], vec![]);
621        assert_eq!(lines[5], vec![(0, 2)]);
622        assert_eq!(lines[6], vec![(0, 3)]);
623        assert_eq!(lines[7], vec![(0, 3)]);
624        assert_eq!(lines[8], vec![(0, 6)]);
625        assert_eq!(lines[9], vec![(0, 7)]);
626        assert_eq!(lines[10], vec![(0, 8)]);
627    }
628
629    #[test]
630    fn innermost_range_prefers_the_tightest_span() {
631        // A module range (count 1) with a nested uncalled-fn range (count 0):
632        // a position inside the fn is uncovered, one outside it is covered.
633        let ranges = vec![
634            V8Range {
635                start: 0,
636                end: 100,
637                count: 1,
638            },
639            V8Range {
640                start: 40,
641                end: 60,
642                count: 0,
643            },
644        ];
645        assert_eq!(innermost_range(10, &ranges), Some((100, 1)));
646        assert_eq!(innermost_range(50, &ranges), Some((20, 0)));
647        assert_eq!(innermost_range(200, &ranges), None); // outside every range
648    }
649
650    #[test]
651    fn file_acc_lets_the_tightest_range_decide() {
652        // The hoisted-export hazard: a `.bynk` line reached both by a wide
653        // module range that ran (the `exports.f = f;` line) and a tight function
654        // range that did not (the never-called body). The tight range wins →
655        // uncovered, not falsely covered.
656        let mut acc = FileAcc::default();
657        acc.observe(7, 352, 1); // module-level hoisted export, executed
658        acc.observe(7, 61, 0); // the function body's own range, never run
659        assert_eq!(acc.lines.get(&7), Some(&(61, 0)));
660    }
661
662    #[test]
663    fn line_representatives_pick_first_nonspace() {
664        let reps = line_representatives("ab\n    cd\n\n  \nx");
665        assert_eq!(reps[0], Some(0)); // "ab"
666        assert_eq!(reps[1], Some(7)); // "    cd" → 'c' at 3+4
667        assert_eq!(reps[2], None); // blank
668        assert_eq!(reps[3], None); // whitespace-only
669        assert_eq!(reps[4], Some(14)); // "x"
670    }
671
672    #[test]
673    fn line_representatives_count_utf16_units_not_bytes() {
674        // An em-dash (`—`, U+2014: 3 UTF-8 bytes, 1 UTF-16 unit) on line 0 must
675        // not shift line 1's offset — V8 counts UTF-16 units, so a byte-based
676        // accumulator would report 9 (7 + 2) here and mis-sample the ranges.
677        let reps = line_representatives("a — b\nx");
678        assert_eq!(reps[0], Some(0)); // "a — b"
679        // "a — b\n" = 6 UTF-16 units (byte length would be 8: '—' is 3 bytes).
680        assert_eq!(reps[1], Some(6)); // "x" at UTF-16 offset 6, not byte 8
681        // An astral char (`🎉`, 2 UTF-16 units) shifts by 2, matching V8.
682        let reps = line_representatives("🎉\ny");
683        assert_eq!(reps[0], Some(0));
684        assert_eq!(reps[1], Some(3)); // 🎉(2) + '\n'(1) = 3
685    }
686
687    #[test]
688    fn format_ranges_compresses_runs() {
689        assert_eq!(format_ranges(&[6, 7, 8, 51]), "6-8, 51");
690        assert_eq!(format_ranges(&[3]), "3");
691        assert_eq!(format_ranges(&[1, 2, 4, 5, 6]), "1-2, 4-6");
692        assert_eq!(format_ranges(&[]), "");
693    }
694
695    #[test]
696    fn percent_rounds_and_guards_zero() {
697        assert_eq!(percent(0, 0), 100);
698        assert_eq!(percent(42, 49), 86);
699        assert_eq!(percent(2, 5), 40);
700        assert_eq!(percent(11, 11), 100);
701    }
702
703    #[test]
704    fn percent_never_reports_100_for_an_incomplete_run() {
705        // Round-half-up would give 100 here; a run with any uncovered line must
706        // read at most 99 so the table and the JSON `percent` never falsely green.
707        assert_eq!(percent(995, 1000), 99);
708        assert_eq!(percent(199, 200), 99);
709        assert_eq!(percent(1000, 1000), 100); // genuinely complete
710        assert_eq!(percent(0, 5), 0);
711    }
712
713    #[test]
714    fn measurable_filter_drops_tests_workers_runtime() {
715        assert!(is_measurable_emitted(Path::new("src/limiter.js")));
716        assert!(is_measurable_emitted(Path::new("limiter.js")));
717        assert!(!is_measurable_emitted(Path::new("tests/main.js")));
718        assert!(!is_measurable_emitted(Path::new("workers/api/handlers.js")));
719        assert!(!is_measurable_emitted(Path::new("runtime.js")));
720        assert!(!is_measurable_emitted(Path::new("src/limiter.js.map")));
721    }
722
723    #[test]
724    fn file_url_round_trips_to_path() {
725        assert_eq!(
726            file_url_to_path("file:///private/tmp/a%20b/out-js/m.js"),
727            Some(PathBuf::from("/private/tmp/a b/out-js/m.js"))
728        );
729        assert_eq!(file_url_to_path("node:internal/modules"), None);
730    }
731}