retroglyph_window/testing.rs
1//! Glyph-coverage testing helpers, feature-gated (`testing`), no effect on release builds.
2//!
3//! A character no font in a [`FontChain`](crate::font::FontChain) covers still renders: it falls back to the substituted
4//! solid block (or, rarely, draws nothing at all -- see
5//! [`FontChain::resolve`](crate::font::FontChain::resolve)). That fallback is a legitimate cell
6//! on its own, so a snapshot test of the rendered output cannot tell "this glyph is missing" from
7//! "this glyph is a block on purpose": both are a stable, correctly-rendered image
8//! (crates-lurey-io/retroglyph#1292). This module is the check that actually asks the question a
9//! snapshot can't: does `chain.resolve(ch)` answer with `ch`'s own glyph, or with the fallback?
10//!
11//! [`diagnostics::warn_notdef_glyph`](crate::diagnostics::warn_notdef_glyph) catches the same gap
12//! at render time in a dev build; this module is for asserting it directly in a test, over
13//! whatever repertoire a consumer's own UI actually draws, without hand-rolling a
14//! resolve-and-check loop or scanning source text for character literals.
15
16use crate::font::FontChain;
17
18/// Whether `ch` renders as its own glyph in `chain`: covered by some font in the chain, not the
19/// substituted notdef fallback.
20///
21/// A character no font in `chain` can draw at all (not even the fallback -- see
22/// [`FontChain::resolve`](crate::font::FontChain::resolve)) is also "not covered": it draws
23/// nothing, which is no more `ch`'s own glyph than the solid block is.
24#[must_use]
25pub fn is_glyph_covered(chain: &FontChain<'_>, ch: char) -> bool {
26 chain.resolve(ch).is_some_and(|glyph| !glyph.is_notdef())
27}
28
29/// Every character in `chars` that does not render as itself in `chain`, in iteration order,
30/// duplicates included.
31///
32/// For a scan over a whole repertoire (a demo's UI text, a game's glyph set) where a test wants
33/// to name every offender in one failure rather than stopping at the first, as
34/// [`assert_glyphs_covered`] does.
35#[must_use]
36pub fn uncovered_glyphs(chain: &FontChain<'_>, chars: impl IntoIterator<Item = char>) -> Vec<char> {
37 chars
38 .into_iter()
39 .filter(|&ch| !is_glyph_covered(chain, ch))
40 .collect()
41}
42
43/// Asserts every character in `chars` renders as itself in `chain`.
44///
45/// # Panics
46///
47/// Panics naming every character in `chars` that [`uncovered_glyphs`] reports, if any.
48pub fn assert_glyphs_covered(chain: &FontChain<'_>, chars: impl IntoIterator<Item = char>) {
49 let missing = uncovered_glyphs(chain, chars);
50 assert!(
51 missing.is_empty(),
52 "chain does not cover (rendered as the fallback instead of their own glyph): {missing:?}"
53 );
54}
55
56#[cfg(test)]
57mod tests {
58 use super::{assert_glyphs_covered, is_glyph_covered, uncovered_glyphs};
59 use crate::font::{BitmapFont, FontChain};
60
61 static PRIMARY_DATA: [u8; 128 * 16] = [0; 128 * 16];
62 const PRIMARY: BitmapFont = BitmapFont::new(&PRIMARY_DATA, 8, 16, 128);
63
64 #[test]
65 fn covered_ascii_char_is_covered() {
66 let chain = FontChain::from(PRIMARY);
67 assert!(is_glyph_covered(&chain, 'A'));
68 }
69
70 #[test]
71 fn char_outside_the_repertoire_is_not_covered() {
72 let chain = FontChain::from(PRIMARY);
73 // Past PRIMARY's 128 glyphs, so this falls back to the solid block.
74 assert!(!is_glyph_covered(&chain, '\u{00C7}'));
75 }
76
77 #[test]
78 fn uncovered_glyphs_reports_only_the_misses() {
79 let chain = FontChain::from(PRIMARY);
80 assert_eq!(
81 uncovered_glyphs(&chain, ['A', '\u{00C7}', 'B', '\u{3042}']),
82 vec!['\u{00C7}', '\u{3042}']
83 );
84 }
85
86 #[test]
87 fn assert_glyphs_covered_passes_for_a_fully_covered_set() {
88 let chain = FontChain::from(PRIMARY);
89 assert_glyphs_covered(&chain, ['A', 'B', 'C']);
90 }
91
92 #[test]
93 #[should_panic(expected = "does not cover")]
94 fn assert_glyphs_covered_panics_naming_the_miss() {
95 let chain = FontChain::from(PRIMARY);
96 assert_glyphs_covered(&chain, ['A', '\u{3042}']);
97 }
98}