Skip to main content

forge/
coverage.rs

1//! Coverage reports.
2
3use crate::result::{TestKind, TestOutcome, TestResult, TestStatus};
4use alloy_primitives::map::{HashMap, HashSet};
5use comfy_table::{
6    Attribute, Cell, Color, Row, Table,
7    presets::{ASCII_FULL, ASCII_MARKDOWN},
8};
9use foundry_common::{fs, shell};
10use foundry_compilers::artifacts::Source;
11use foundry_evm::core::bytecode::InstIter;
12use semver::Version;
13use serde::{Serialize, ser::SerializeSeq};
14use std::{
15    collections::{BTreeMap, hash_map},
16    io::Write,
17    path::{Path, PathBuf},
18};
19
20pub use foundry_evm::coverage::*;
21
22/// A coverage reporter.
23pub trait CoverageReporter {
24    /// Returns a debug string for the reporter.
25    fn name(&self) -> &'static str;
26
27    /// Returns `true` if the reporter needs source maps for the final report.
28    fn needs_source_maps(&self) -> bool {
29        false
30    }
31
32    /// Runs the reporter.
33    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
34}
35
36/// A simple summary reporter that prints the coverage results in a table.
37pub struct CoverageSummaryReporter {
38    /// The summary table.
39    table: Table,
40    /// The total coverage of the entire project.
41    total: CoverageSummary,
42}
43
44impl Default for CoverageSummaryReporter {
45    fn default() -> Self {
46        let mut table = Table::new();
47        if shell::is_markdown() {
48            table.load_style(ASCII_MARKDOWN);
49        } else {
50            table.load_style(ASCII_FULL.with_rounded_corners());
51        }
52
53        table.set_header(vec![
54            Cell::new("File"),
55            Cell::new("% Lines"),
56            Cell::new("% Statements"),
57            Cell::new("% Branches"),
58            Cell::new("% Funcs"),
59        ]);
60
61        Self { table, total: CoverageSummary::new() }
62    }
63}
64
65impl CoverageSummaryReporter {
66    fn add_row(&mut self, name: impl Into<Cell>, summary: CoverageSummary) {
67        let mut row = Row::new();
68        row.add_cell(name.into())
69            .add_cell(format_cell(summary.line_hits, summary.line_count))
70            .add_cell(format_cell(summary.statement_hits, summary.statement_count))
71            .add_cell(format_cell(summary.branch_hits, summary.branch_count))
72            .add_cell(format_cell(summary.function_hits, summary.function_count));
73        self.table.add_row(row);
74    }
75}
76
77impl CoverageReporter for CoverageSummaryReporter {
78    fn name(&self) -> &'static str {
79        "summary"
80    }
81
82    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
83        for (path, summary) in report.summary_by_file() {
84            self.total.merge(&summary);
85            self.add_row(path.display(), summary);
86        }
87
88        self.add_row("Total", self.total.clone());
89        sh_println!("\n{}", self.table)?;
90        Ok(())
91    }
92}
93
94fn format_cell(hits: usize, total: usize) -> Cell {
95    if total == 0 {
96        return Cell::new(format!("N/A ({hits}/{total})"))
97            .fg(Color::Grey)
98            .add_attribute(Attribute::Dim);
99    }
100
101    let percentage = hits as f64 / total as f64;
102    Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage {
103        _ if percentage < 0.5 => Color::Red,
104        _ if percentage < 0.75 => Color::Yellow,
105        _ => Color::Green,
106    })
107}
108
109/// Writes the coverage report in [LCOV]'s [tracefile format].
110///
111/// [LCOV]: https://github.com/linux-test-project/lcov
112/// [tracefile format]: https://man.archlinux.org/man/geninfo.1.en#TRACEFILE_FORMAT
113pub struct LcovReporter {
114    path: PathBuf,
115    version: Version,
116}
117
118impl LcovReporter {
119    /// Create a new LCOV reporter.
120    pub const fn new(path: PathBuf, version: Version) -> Self {
121        Self { path, version }
122    }
123}
124
125impl CoverageReporter for LcovReporter {
126    fn name(&self) -> &'static str {
127        "lcov"
128    }
129
130    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
131        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
132
133        let mut fn_index = 0usize;
134        for (path, items) in report.items_by_file() {
135            let summary = CoverageSummary::from_items(&items);
136
137            writeln!(out, "TN:")?;
138            writeln!(out, "SF:{}", path.display())?;
139
140            // First pass: collect line hits for DA records.
141            // Track both which lines have been recorded and the max hits per line.
142            let mut line_hits: HashMap<u32, u32> = HashMap::default();
143            for item in &items {
144                if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) {
145                    let line = item.loc.lines.start;
146                    line_hits
147                        .entry(line)
148                        .and_modify(|h| *h = (*h).max(item.hits))
149                        .or_insert(item.hits);
150                }
151            }
152
153            let mut recorded_lines = HashSet::new();
154
155            for item in items {
156                let line = item.loc.lines.start;
157                // `lines` is half-open, so we need to subtract 1 to get the last included line.
158                let end_line = item.loc.lines.end - 1;
159                let hits = item.hits;
160                match item.kind {
161                    CoverageItemKind::Function { ref name } => {
162                        // Free (file-level) functions have no contract scope; emit the bare
163                        // name rather than a leading-dot `.name`.
164                        let name = if item.loc.contract_name.is_empty() {
165                            name.to_string()
166                        } else {
167                            format!("{}.{name}", item.loc.contract_name)
168                        };
169                        if self.version >= Version::new(2, 2, 0) {
170                            // v2.2 changed the FN format.
171                            writeln!(out, "FNL:{fn_index},{line},{end_line}")?;
172                            writeln!(out, "FNA:{fn_index},{hits},{name}")?;
173                            fn_index += 1;
174                        } else if self.version >= Version::new(2, 0, 0) {
175                            // v2.0 added end_line to FN.
176                            writeln!(out, "FN:{line},{end_line},{name}")?;
177                            writeln!(out, "FNDA:{hits},{name}")?;
178                        } else {
179                            writeln!(out, "FN:{line},{name}")?;
180                            writeln!(out, "FNDA:{hits},{name}")?;
181                        }
182                    }
183                    // Add lines / statement hits only once.
184                    CoverageItemKind::Line | CoverageItemKind::Statement
185                        if recorded_lines.insert(line) =>
186                    {
187                        writeln!(out, "DA:{line},{}", line_hits[&line])?;
188                    }
189                    CoverageItemKind::Branch { branch_id, path_id, .. } => {
190                        // Per LCOV spec: "-" means the expression was never evaluated (line not
191                        // executed), "0" means branch exists but was never taken.
192                        // Check if the line containing this branch was hit.
193                        let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0);
194                        let hits_str = if hits > 0 {
195                            hits.to_string()
196                        } else if line_was_hit {
197                            "0".to_string()
198                        } else {
199                            "-".to_string()
200                        };
201                        writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?;
202                    }
203                    _ => {}
204                }
205            }
206
207            // Function summary
208            writeln!(out, "FNF:{}", summary.function_count)?;
209            writeln!(out, "FNH:{}", summary.function_hits)?;
210
211            // Line summary
212            writeln!(out, "LF:{}", summary.line_count)?;
213            writeln!(out, "LH:{}", summary.line_hits)?;
214
215            // Branch summary
216            writeln!(out, "BRF:{}", summary.branch_count)?;
217            writeln!(out, "BRH:{}", summary.branch_hits)?;
218
219            writeln!(out, "end_of_record")?;
220        }
221
222        out.flush()?;
223        sh_println!("Wrote LCOV report.")?;
224
225        Ok(())
226    }
227}
228
229/// Writes per-test coverage attribution as JSON.
230pub struct CoverageAttributionReporter {
231    path: PathBuf,
232}
233
234/// A hit map resolved to the contract coverage metadata it belongs to.
235pub struct ResolvedHitMap {
236    pub contract_id: ContractId,
237    pub is_deployed_code: bool,
238}
239
240pub type ResolvedHitMaps = alloy_primitives::map::B256HashMap<ResolvedHitMap>;
241
242impl CoverageAttributionReporter {
243    /// Create a new attribution reporter.
244    pub const fn new(path: PathBuf) -> Self {
245        Self { path }
246    }
247
248    /// Writes per-test coverage attribution for the provided outcome.
249    pub fn report(
250        &self,
251        report: &CoverageReport,
252        outcome: &TestOutcome,
253        resolved_hit_maps: &ResolvedHitMaps,
254    ) -> eyre::Result<()> {
255        let payload = AttributionReport {
256            version: 1,
257            tests: AttributionTests { report, outcome, resolved_hit_maps },
258        };
259        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
260        serde_json::to_writer(&mut out, &payload)?;
261        writeln!(out)?;
262        out.flush()?;
263
264        sh_println!("Wrote coverage attribution report.")?;
265
266        Ok(())
267    }
268}
269
270/// Top-level JSON payload for per-test coverage attribution.
271#[derive(Serialize)]
272struct AttributionReport<'a> {
273    version: u8,
274    tests: AttributionTests<'a>,
275}
276
277/// Coverage attributed to a single executed test.
278#[derive(Serialize)]
279struct AttributionTest {
280    suite: String,
281    test: String,
282    status: &'static str,
283    kind: &'static str,
284    covered: Vec<AttributionItem>,
285}
286
287/// A source range covered by a test, with hit counts and item metadata.
288#[derive(Serialize)]
289struct AttributionItem {
290    source: String,
291    contract: String,
292    kind: &'static str,
293    /// The start of a 1-based, half-open line range.
294    line_start: u32,
295    /// The end of a 1-based, half-open line range.
296    line_end: u32,
297    /// The start of a 0-based, half-open byte range.
298    byte_start: u32,
299    /// The end of a 0-based, half-open byte range.
300    byte_end: u32,
301    hits: u32,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    function: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    branch_id: Option<u32>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    path_id: Option<u32>,
308}
309
310/// Serializer state for streaming attribution entries from test results.
311struct AttributionTests<'a> {
312    report: &'a CoverageReport,
313    outcome: &'a TestOutcome,
314    resolved_hit_maps: &'a ResolvedHitMaps,
315}
316
317impl Serialize for AttributionTests<'_> {
318    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
319    where
320        S: serde::Serializer,
321    {
322        let len = self.outcome.results.values().map(|suite| suite.test_results.len()).sum();
323        let mut seq = serializer.serialize_seq(Some(len))?;
324
325        for (suite, suite_result) in &self.outcome.results {
326            for (test, result) in &suite_result.test_results {
327                seq.serialize_element(&AttributionTest {
328                    suite: suite.clone(),
329                    test: test.clone(),
330                    status: test_status_name(result.status),
331                    kind: test_kind_name(&result.kind),
332                    covered: attributed_items(self.report, self.resolved_hit_maps, result),
333                })?;
334            }
335        }
336
337        seq.end()
338    }
339}
340
341fn attributed_items(
342    report: &CoverageReport,
343    resolved_hit_maps: &ResolvedHitMaps,
344    result: &TestResult,
345) -> Vec<AttributionItem> {
346    type AttributionItemKey = (
347        String,
348        String,
349        &'static str,
350        u32,
351        u32,
352        u32,
353        u32,
354        Option<String>,
355        Option<u32>,
356        Option<u32>,
357    );
358
359    let mut items = BTreeMap::<AttributionItemKey, AttributionItem>::new();
360    let Some(hit_maps) = result.line_coverage.as_ref() else { return Vec::new() };
361
362    for (code_hash, map) in &hit_maps.0 {
363        let Some(resolved) = resolved_hit_maps.get(code_hash) else { continue };
364
365        for (item, hits) in
366            report.hit_items_for_hit_map(&resolved.contract_id, map, resolved.is_deployed_code)
367        {
368            let Some(source_path) =
369                report.get_source_path(&resolved.contract_id.build_id, item.loc.source_id)
370            else {
371                continue;
372            };
373
374            let source = source_path.display().to_string();
375            let contract = item.loc.contract_name.to_string();
376            let (kind, function, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
377            let line_start = item.loc.lines.start;
378            let line_end = item.loc.lines.end;
379            let byte_start = item.loc.bytes.start;
380            let byte_end = item.loc.bytes.end;
381            let key = (
382                source.clone(),
383                contract.clone(),
384                kind,
385                line_start,
386                line_end,
387                byte_start,
388                byte_end,
389                function.clone(),
390                branch_id,
391                path_id,
392            );
393
394            items.entry(key).and_modify(|item| item.hits += hits).or_insert(AttributionItem {
395                source,
396                contract,
397                kind,
398                line_start,
399                line_end,
400                byte_start,
401                byte_end,
402                hits,
403                function,
404                branch_id,
405                path_id,
406            });
407        }
408    }
409
410    items.into_values().collect()
411}
412
413fn coverage_item_kind_fields(
414    kind: &CoverageItemKind,
415) -> (&'static str, Option<String>, Option<u32>, Option<u32>) {
416    match kind {
417        CoverageItemKind::Line => ("line", None, None, None),
418        CoverageItemKind::Statement => ("statement", None, None, None),
419        CoverageItemKind::Branch { branch_id, path_id, .. } => {
420            ("branch", None, Some(*branch_id), Some(*path_id))
421        }
422        CoverageItemKind::Function { name } => ("function", Some(name.to_string()), None, None),
423    }
424}
425
426const fn test_status_name(status: TestStatus) -> &'static str {
427    match status {
428        TestStatus::Success => "success",
429        TestStatus::Failure => "failure",
430        TestStatus::Skipped => "skipped",
431    }
432}
433
434const fn test_kind_name(kind: &TestKind) -> &'static str {
435    match kind {
436        TestKind::Unit { .. } => "unit",
437        TestKind::Fuzz { .. } => "fuzz",
438        TestKind::Invariant { .. } => "invariant",
439        TestKind::Table { .. } => "table",
440        TestKind::Symbolic { .. } => "symbolic",
441        TestKind::Replay { .. } => "replay",
442    }
443}
444
445/// A super verbose reporter for debugging coverage while it is still unstable.
446pub struct DebugReporter;
447
448impl CoverageReporter for DebugReporter {
449    fn name(&self) -> &'static str {
450        "debug"
451    }
452
453    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
454        for (path, items) in report.items_by_file() {
455            let src = fs::read_to_string(path)?;
456            sh_println!("{}:", path.display())?;
457            for item in items {
458                sh_println!("- {}", item.fmt_with_source(Some(&src)))?;
459            }
460            sh_println!()?;
461        }
462
463        for (contract_id, (cta, rta)) in &report.anchors {
464            if cta.anchors.is_empty() && rta.anchors.is_empty() {
465                continue;
466            }
467
468            let anchors = cta
469                .anchors
470                .iter()
471                .map(|anchor| (false, anchor))
472                .chain(rta.anchors.iter().map(|anchor| (true, anchor)))
473                .filter_map(|(is_runtime, anchor)| {
474                    let item = report
475                        .analyses
476                        .get(&contract_id.build_id)
477                        .and_then(|items| items.get(anchor.item_id))?;
478                    // Source filters retain analyses to keep anchor item IDs stable, so debug
479                    // output must apply the same reportable-source filter as other reporters.
480                    report
481                        .get_source_path(&contract_id.build_id, item.loc.source_id)
482                        .is_some()
483                        .then_some((is_runtime, anchor, item))
484                })
485                .collect::<Vec<_>>();
486            if anchors.is_empty() {
487                continue;
488            }
489
490            sh_println!("Anchors for {contract_id}:")?;
491            for (is_runtime, anchor, item) in anchors {
492                let kind = if is_runtime { " runtime" } else { "creation" };
493                sh_println!("- {kind} {anchor}: {item}")?;
494            }
495            sh_println!()?;
496        }
497
498        Ok(())
499    }
500}
501
502pub struct BytecodeReporter {
503    root: PathBuf,
504    destdir: PathBuf,
505}
506
507impl BytecodeReporter {
508    pub const fn new(root: PathBuf, destdir: PathBuf) -> Self {
509        Self { root, destdir }
510    }
511}
512
513impl CoverageReporter for BytecodeReporter {
514    fn name(&self) -> &'static str {
515        "bytecode"
516    }
517
518    fn needs_source_maps(&self) -> bool {
519        true
520    }
521
522    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
523        use std::fmt::Write;
524
525        fs::create_dir_all(&self.destdir)?;
526
527        let no_source_elements = Vec::new();
528        let mut line_number_cache = LineNumberCache::new(self.root.clone());
529
530        for (contract_id, hits) in &report.bytecode_hits {
531            let mut formatted = String::new();
532
533            let source_elements =
534                report.source_maps.get(contract_id).map(|sm| &sm.1).unwrap_or(&no_source_elements);
535
536            let ops = InstIter::new(hits.bytecode()).with_pc();
537            for ((pc, inst), source_element) in std::iter::zip(ops, source_elements) {
538                let hits =
539                    hits.get(pc as u32).map(|h| format!("[{h:03}]")).unwrap_or("     ".to_owned());
540                let source_id = source_element.index();
541                let source_path = source_id
542                    .and_then(|i| report.get_source_path(&contract_id.build_id, i as usize));
543
544                let code = format!("{pc:08x}: {inst}");
545                let start = source_element.offset() as usize;
546                let end = (source_element.offset() + source_element.length()) as usize;
547
548                if let Some(source_path) = source_path {
549                    let (sline, spos) = line_number_cache.get_position(source_path, start)?;
550                    let (eline, epos) = line_number_cache.get_position(source_path, end)?;
551                    writeln!(
552                        formatted,
553                        "{} {:40} // {}: {}:{}-{}:{} ({}-{})",
554                        hits,
555                        code,
556                        source_path.display(),
557                        sline,
558                        spos,
559                        eline,
560                        epos,
561                        start,
562                        end
563                    )?;
564                } else if let Some(source_id) = source_id {
565                    writeln!(formatted, "{hits} {code:40} // SRCID{source_id}: ({start}-{end})")?;
566                } else {
567                    writeln!(formatted, "{hits} {code:40}")?;
568                }
569            }
570            fs::write(
571                self.destdir.join(&*contract_id.contract_name).with_extension("asm"),
572                formatted,
573            )?;
574        }
575
576        Ok(())
577    }
578}
579
580/// Cache line number offsets for source files
581struct LineNumberCache {
582    root: PathBuf,
583    line_offsets: HashMap<PathBuf, Vec<usize>>,
584}
585
586impl LineNumberCache {
587    pub fn new(root: PathBuf) -> Self {
588        Self { root, line_offsets: HashMap::default() }
589    }
590
591    pub fn get_position(&mut self, path: &Path, offset: usize) -> eyre::Result<(usize, usize)> {
592        let line_offsets = match self.line_offsets.entry(path.to_path_buf()) {
593            hash_map::Entry::Occupied(o) => o.into_mut(),
594            hash_map::Entry::Vacant(v) => {
595                let text = fs::read_to_string(self.root.join(path))?;
596                let mut line_offsets = vec![0];
597                for line in text.lines() {
598                    let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
599                    line_offsets.push(line_offset);
600                }
601                v.insert(line_offsets)
602            }
603        };
604        let lo = match line_offsets.binary_search(&offset) {
605            Ok(lo) => lo,
606            Err(lo) => lo - 1,
607        };
608        let pos = offset - line_offsets.get(lo).unwrap() + 1;
609        Ok((lo, pos))
610    }
611}
612
613/// Writes the uncovered coverage items of each source file as JSON.
614pub struct GapsReporter {
615    root: PathBuf,
616    path: PathBuf,
617}
618
619impl GapsReporter {
620    /// Create a new gaps reporter.
621    pub const fn new(root: PathBuf, path: PathBuf) -> Self {
622        Self { root, path }
623    }
624}
625
626impl CoverageReporter for GapsReporter {
627    fn name(&self) -> &'static str {
628        "gaps"
629    }
630
631    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
632        let mut files = BTreeMap::new();
633        for (path, items) in report.items_by_file() {
634            if items.iter().all(|item| item.hits > 0) {
635                continue;
636            }
637
638            // Item offsets refer to the source the compiler read, with CRLF normalized to LF.
639            let src = Source::read(&self.root.join(path))?.content;
640            let functions = items
641                .iter()
642                .filter_map(|item| match &item.kind {
643                    CoverageItemKind::Function { name } => Some((&item.loc.bytes, name)),
644                    _ => None,
645                })
646                .collect::<Vec<_>>();
647            let mut uncovered = items
648                .iter()
649                .filter(|item| item.hits == 0)
650                .map(|item| {
651                    let (kind, name, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
652                    let function = name.or_else(|| {
653                        functions
654                            .iter()
655                            .find(|(bytes, _)| {
656                                bytes.start <= item.loc.bytes.start
657                                    && item.loc.bytes.end <= bytes.end
658                            })
659                            .map(|(_, name)| name.to_string())
660                    });
661                    let snippet = src
662                        .get(item.loc.bytes())
663                        .and_then(|text| text.lines().next())
664                        .unwrap_or_default()
665                        .trim_end();
666                    // A line item reuses the location of the first item on its line, so keep only
667                    // that line. `lines` is half-open, so subtract 1 to get the last included line.
668                    let (line_end, byte_end) = if matches!(item.kind, CoverageItemKind::Line) {
669                        (item.loc.lines.start, item.loc.bytes.start + snippet.len() as u32)
670                    } else {
671                        (item.loc.lines.end - 1, item.loc.bytes.end)
672                    };
673                    GapItem {
674                        kind,
675                        contract: item.loc.contract_name.to_string(),
676                        function,
677                        line_start: item.loc.lines.start,
678                        line_end,
679                        byte_start: item.loc.bytes.start,
680                        byte_end,
681                        snippet: snippet.to_string(),
682                        branch_id,
683                        path_id,
684                    }
685                })
686                .collect::<Vec<_>>();
687            // Drop statements that only repeat a line item.
688            let lines = uncovered
689                .iter()
690                .filter(|item| item.kind == "line")
691                .map(|item| (item.byte_start, item.byte_end))
692                .collect::<HashSet<_>>();
693            uncovered.retain(|item| {
694                item.kind != "statement" || !lines.contains(&(item.byte_start, item.byte_end))
695            });
696            files.insert(path, uncovered);
697        }
698
699        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
700        serde_json::to_writer_pretty(&mut out, &GapsReport { version: 1, files })?;
701        writeln!(out)?;
702        out.flush()?;
703
704        sh_println!("Wrote coverage gaps report.")?;
705
706        Ok(())
707    }
708}
709
710/// Top-level JSON payload for the coverage gaps report.
711#[derive(Serialize)]
712struct GapsReport<'a> {
713    version: u8,
714    files: BTreeMap<&'a Path, Vec<GapItem>>,
715}
716
717/// An uncovered coverage item.
718#[derive(Serialize)]
719struct GapItem {
720    kind: &'static str,
721    contract: String,
722    /// The function that contains the item.
723    #[serde(skip_serializing_if = "Option::is_none")]
724    function: Option<String>,
725    /// The first line of the item, 1-based.
726    line_start: u32,
727    /// The last line of the item, 1-based and inclusive.
728    line_end: u32,
729    /// The start of a 0-based byte range in the LF-normalized source.
730    byte_start: u32,
731    /// The end of the byte range, exclusive.
732    byte_end: u32,
733    /// The first source line of the item.
734    snippet: String,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    branch_id: Option<u32>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    path_id: Option<u32>,
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    #[test]
746    fn empty_summary_cell_is_not_applicable() {
747        assert_eq!(
748            format_cell(0, 0),
749            Cell::new("N/A (0/0)").fg(Color::Grey).add_attribute(Attribute::Dim)
750        );
751    }
752}