Files
Ferrous-Solitaire/solitaire_engine/tests/card_face_svg_pin.rs
T
funman300 af414b6aed fix(engine): render card suit glyphs as SVG paths instead of text
The user's first post-migration screenshot showed near-invisible
suit glyphs on every card — the rank rendered at correct size but
the ♠ ♥ ♦ ♣ marks were tiny dots regardless of the requested
20px / 64px font-size.

Root cause: the bundled FiraMono in svg_loader::shared_fontdb
doesn't carry usable Unicode suit glyphs (U+2660-2666). usvg
silently fell back to a substitute rendering at default size,
producing the "tofu" effect.

Fixes by replacing the `<text>` glyph rendering with inline SVG
paths. `suit_path_d(suit)` returns a single closed-perimeter path
authored in a 32 × 32 logical box, then face_svg wraps it in two
`<g transform>` blocks (top-left small + bottom-right rotated
large). Path-based rendering bypasses the font system entirely
— same bytes on every machine, no fontdb dependency, no
substitution risk.

Same path data renders correctly whether filled (♥ ♠) or
outlined (♦ ♣ — the always-on color-blind glyph differentiation
from the design system).

Knock-on changes that must land in this commit per the migration
plan's lockstep rule:

- `EXPECTED` in tests/card_face_svg_pin.rs rebaselined: 52 face
  hashes change (text → path), 5 back hashes unchanged
  (back_svg untouched). The bootstrap pattern in the test
  handled the rebaseline cleanly — empty EXPECTED, re-run,
  paste, re-run.
- assets/cards/faces/*.png regenerated (the 52 face PNGs).
- solitaire_engine/assets/themes/default/*_*.svg regenerated
  (the 52 theme face SVGs that production rasterises at
  startup). Both rendering paths must agree.

Workspace clippy + cargo test --workspace clean. Pin test
passes against the new hashes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:02:04 -07:00

176 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pinning test for the Terminal card-face SVG builders.
//!
//! Hashes the raw RGBA8 pixel bytes produced by rasterising every
//! `face_svg` × `back_svg` output through `assets::rasterize_svg`,
//! and compares each hash to an embedded constant. Catches silent
//! rendering drift if `usvg`, `resvg`, `tiny_skia`, or the bundled
//! `FiraMono` font ever change in a way that perturbs the rendered
//! pixels.
//!
//! When the SVG builders intentionally change (or a dependency
//! upgrade legitimately changes rendering), update `EXPECTED` by
//! emptying it (`&[]`) and re-running this test once — the test
//! will panic with the new hashes formatted as Rust source ready
//! to paste back in.
//!
//! Hashing is FNV-1a 64-bit on the raw RGBA byte buffer. PNG
//! compression is intentionally not in the loop — we only want
//! the test to fire on actual pixel changes, not zlib-level
//! shifts that don't affect what a player sees.
use solitaire_engine::assets::card_face_svg::{
back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET,
};
use solitaire_engine::assets::rasterize_svg;
const EXPECTED: &[(&str, u64)] = &[
("face_AC", 0x79b449cb455e496d),
("face_2C", 0x10a1056c4800c45e),
("face_3C", 0xbd128e390e06673a),
("face_4C", 0x949c323c78a804c0),
("face_5C", 0xd396d5ed99fb57e9),
("face_6C", 0x15519c6d72d1720f),
("face_7C", 0xc24bdc1a2d380d78),
("face_8C", 0x36464f4ab4cf672e),
("face_9C", 0x32add2eb53b1aec4),
("face_10C", 0x68619202f29481fc),
("face_JC", 0x116b3eeac58e0f58),
("face_QC", 0xb149ab5b2cac85e3),
("face_KC", 0x2a9fd2c63b99bd3b),
("face_AD", 0xe49c3fec2c01817c),
("face_2D", 0x8f42b4014e0d6809),
("face_3D", 0x63ff77fa873c557b),
("face_4D", 0x33356bd9628daaf2),
("face_5D", 0x8897839054dbd808),
("face_6D", 0x03ff93fb0c05a195),
("face_7D", 0xc2b7f97f5b1cc545),
("face_8D", 0xd8515a8278d74a7b),
("face_9D", 0xfbfe52ec3bbd2962),
("face_10D", 0x8f2dfc06a1d55a2f),
("face_JD", 0x3941d34384607530),
("face_QD", 0x0dcf5a9e2fc99f02),
("face_KD", 0xb834cb89d80bd39c),
("face_AH", 0x1a2e6d2ac818093f),
("face_2H", 0x8ab9ad7d2111233e),
("face_3H", 0x5e1057fa87c90968),
("face_4H", 0x1e1550b0af8a35a5),
("face_5H", 0x77404642251596d3),
("face_6H", 0xf7bec77bcbb9f942),
("face_7H", 0x9b7c52a5c03fb4f2),
("face_8H", 0xd2623a827963fe68),
("face_9H", 0xec19380e53986015),
("face_10H", 0x1205d0ec042a7484),
("face_JH", 0xd28bf03e6e871ccb),
("face_QH", 0x78548704b4530c65),
("face_KH", 0x9708e6c2d9c3bedf),
("face_AS", 0xebabc54128f38105),
("face_2S", 0xaac2970387b18ffe),
("face_3S", 0xb0864e78a6802bea),
("face_4S", 0xd118bc992bd41330),
("face_5S", 0x7fb7d6040d9b0641),
("face_6S", 0xbc048e82f1079637),
("face_7S", 0x147ee7c002e43648),
("face_8S", 0xfed30db056fbaa8e),
("face_9S", 0x332bc2060d8fcca4),
("face_10S", 0x0b810ffaf105421c),
("face_JS", 0x2ea7b956f2f23c28),
("face_QS", 0xedca2e002087ae6b),
("face_KS", 0x92e486d4e96ac4a3),
("back_0", 0xf698d0e161eae13a),
("back_1", 0x446fdc0a3c83a03a),
("back_2", 0xcf188fdec9f5819a),
("back_3", 0xcaffd02af141743a),
("back_4", 0xcee8a700bbaaf71a),
];
#[test]
fn rasterised_card_bytes_match_pinned_hashes() {
let actual = compute_actual_hashes();
if EXPECTED.is_empty() {
panic_with_hashes_to_paste(&actual);
}
assert_eq!(
actual.len(),
EXPECTED.len(),
"card-output count drifted (actual {} vs expected {})",
actual.len(),
EXPECTED.len(),
);
let mut mismatches: Vec<String> = Vec::new();
for ((actual_name, actual_hash), (expected_name, expected_hash)) in
actual.iter().zip(EXPECTED.iter())
{
assert_eq!(
actual_name, expected_name,
"card-output naming/order drifted",
);
if actual_hash != expected_hash {
mismatches.push(format!(
" {actual_name}: actual 0x{actual_hash:016x} expected 0x{expected_hash:016x}",
));
}
}
if !mismatches.is_empty() {
let mut msg = String::from(
"rasterised card bytes drifted from EXPECTED — usvg/resvg/tiny_skia/font upgrade?\n",
);
for m in &mismatches {
msg.push_str(m);
msg.push('\n');
}
msg.push_str(
"\nIf this drift is intentional, replace EXPECTED with `&[]` and re-run\nthis test to print fresh hashes.\n",
);
panic!("{msg}");
}
}
fn compute_actual_hashes() -> Vec<(String, u64)> {
let mut out = Vec::with_capacity(ALL_RANKS.len() * ALL_SUITS.len() + BACK_ACCENTS.len());
for suit in ALL_SUITS {
for rank in ALL_RANKS {
let name = format!("face_{}{}", rank_filename(rank), suit_filename(suit));
out.push((name, hash_rasterised(&face_svg(rank, suit))));
}
}
for (idx, accent) in BACK_ACCENTS.iter().enumerate() {
out.push((format!("back_{idx}"), hash_rasterised(&back_svg(accent))));
}
out
}
fn hash_rasterised(svg: &str) -> u64 {
let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG");
let bytes = image.data.expect("rasterised image carries RGBA pixel data");
fnv1a(&bytes)
}
/// FNV-1a 64-bit. Inline rather than a dependency — adding `sha2`
/// or `blake3` for ~5 lines of code would burn a CLAUDE.md §8
/// "ask before adding deps" round-trip for no real benefit.
/// Cryptographic strength isn't load-bearing here — we just need
/// stable byte fingerprints.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn panic_with_hashes_to_paste(actual: &[(String, u64)]) -> ! {
let mut out = String::from(
"\nEXPECTED is empty — paste the following into the const literal:\n\nconst EXPECTED: &[(&str, u64)] = &[\n",
);
for (name, hash) in actual {
out.push_str(&format!(" (\"{name}\", 0x{hash:016x}),\n"));
}
out.push_str("];\n");
panic!("{out}");
}