Skip to main content

pixelsrc/
explain.rs

1//! Human-readable sprite explanations
2//!
3//! Provides explanations of sprite structure, tokens, colors, and patterns
4//! for AI assistants and human users to understand sprite definitions.
5
6use std::collections::HashMap;
7
8use crate::color::parse_color;
9use crate::models::{Animation, Composition, PaletteRef, Particle, Sprite, TtpObject, Variant};
10use crate::palettes;
11use crate::tokenizer::tokenize;
12
13/// Token usage statistics within a sprite
14#[derive(Debug, Clone)]
15pub struct TokenUsage {
16    /// Token name (e.g., "{skin}")
17    pub token: String,
18    /// Number of times used in the grid
19    pub count: usize,
20    /// Percentage of total grid cells
21    pub percentage: f64,
22    /// Resolved color value (if available)
23    pub color: Option<String>,
24    /// Human-readable color name (if determinable)
25    pub color_name: Option<String>,
26}
27
28/// Explanation of a sprite's structure and content
29#[derive(Debug)]
30pub struct SpriteExplanation {
31    /// Sprite name
32    pub name: String,
33    /// Width in pixels/tokens
34    pub width: usize,
35    /// Height in pixels/rows
36    pub height: usize,
37    /// Total number of cells in the grid
38    pub total_cells: usize,
39    /// Palette reference (name or "inline")
40    pub palette_ref: String,
41    /// Token usage statistics, sorted by frequency
42    pub tokens: Vec<TokenUsage>,
43    /// Number of transparent cells
44    pub transparent_count: usize,
45    /// Percentage of transparent cells
46    pub transparency_ratio: f64,
47    /// Whether the sprite has consistent row widths
48    pub consistent_rows: bool,
49    /// Issues or warnings about the sprite
50    pub issues: Vec<String>,
51}
52
53/// Explanation of a palette's structure
54#[derive(Debug)]
55pub struct PaletteExplanation {
56    /// Palette name
57    pub name: String,
58    /// Number of colors defined
59    pub color_count: usize,
60    /// Color mappings (token -> color)
61    pub colors: Vec<(String, String, Option<String>)>,
62    /// Whether this is a built-in palette
63    pub is_builtin: bool,
64}
65
66/// Explanation of an animation's structure
67#[derive(Debug)]
68pub struct AnimationExplanation {
69    /// Animation name
70    pub name: String,
71    /// Frame sprite names
72    pub frames: Vec<String>,
73    /// Frame count
74    pub frame_count: usize,
75    /// Duration per frame in ms
76    pub duration_ms: u32,
77    /// Whether it loops
78    pub loops: bool,
79}
80
81/// Explanation of a composition's structure
82#[derive(Debug)]
83pub struct CompositionExplanation {
84    /// Composition name
85    pub name: String,
86    /// Base sprite (if any)
87    pub base: Option<String>,
88    /// Canvas size
89    pub size: Option<[u32; 2]>,
90    /// Cell size for tiling
91    pub cell_size: [u32; 2],
92    /// Sprite mappings
93    pub sprite_count: usize,
94    /// Layer count
95    pub layer_count: usize,
96}
97
98/// Explanation of a variant's structure
99#[derive(Debug)]
100pub struct VariantExplanation {
101    /// Variant name
102    pub name: String,
103    /// Base sprite name
104    pub base: String,
105    /// Number of color overrides
106    pub override_count: usize,
107    /// Color overrides (token -> new color)
108    pub overrides: Vec<(String, String)>,
109}
110
111/// Explanation of a particle system
112#[derive(Debug, Clone)]
113pub struct ParticleExplanation {
114    /// Particle system name
115    pub name: String,
116    /// Source sprite name
117    pub sprite: String,
118    /// Emission rate (particles per frame)
119    pub rate: f64,
120    /// Lifetime range [min, max] in frames
121    pub lifetime: [u32; 2],
122    /// Whether gravity is applied
123    pub has_gravity: bool,
124    /// Whether particles fade out
125    pub has_fade: bool,
126}
127
128/// Explanation of a user-defined transform
129#[derive(Debug, Clone)]
130pub struct TransformExplanation {
131    /// Transform name
132    pub name: String,
133    /// Whether it has parameters
134    pub is_parameterized: bool,
135    /// Parameter names (if parameterized)
136    pub params: Vec<String>,
137    /// Whether it generates animation frames
138    pub generates_animation: bool,
139    /// Number of frames (if animation)
140    pub frame_count: Option<u32>,
141    /// Transform type description
142    pub transform_type: String,
143}
144
145/// Unified explanation for any pixelsrc object
146#[derive(Debug)]
147pub enum Explanation {
148    Sprite(SpriteExplanation),
149    Palette(PaletteExplanation),
150    Transform(TransformExplanation),
151    Animation(AnimationExplanation),
152    Composition(CompositionExplanation),
153    Variant(VariantExplanation),
154    Particle(ParticleExplanation),
155}
156
157/// Analyze a sprite and produce an explanation
158pub fn explain_sprite(
159    sprite: &Sprite,
160    palette_colors: Option<&HashMap<String, String>>,
161) -> SpriteExplanation {
162    let mut token_counts: HashMap<String, usize> = HashMap::new();
163    let mut total_cells = 0;
164    let mut first_row_width: Option<usize> = None;
165    let mut consistent_rows = true;
166    let mut issues = Vec::new();
167
168    // Analyze each row
169    for (row_idx, row) in sprite.grid.iter().enumerate() {
170        let (tokens, warnings) = tokenize(row);
171        let row_width = tokens.len();
172
173        // Check row consistency
174        match first_row_width {
175            None => first_row_width = Some(row_width),
176            Some(expected) if row_width != expected => {
177                consistent_rows = false;
178                issues.push(format!(
179                    "Row {} has {} tokens (expected {})",
180                    row_idx + 1,
181                    row_width,
182                    expected
183                ));
184            }
185            _ => {}
186        }
187
188        // Count tokens
189        for token in tokens {
190            *token_counts.entry(token).or_insert(0) += 1;
191            total_cells += 1;
192        }
193
194        // Collect tokenization warnings
195        for warning in warnings {
196            issues.push(warning.message);
197        }
198    }
199
200    let width = first_row_width.unwrap_or(0);
201    let height = sprite.grid.len();
202
203    // Calculate transparency
204    let transparent_count = token_counts.get("{_}").copied().unwrap_or(0);
205    let transparency_ratio =
206        if total_cells > 0 { (transparent_count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
207
208    // Build token usage list
209    let mut tokens: Vec<TokenUsage> = token_counts
210        .iter()
211        .map(|(token, &count)| {
212            let percentage =
213                if total_cells > 0 { (count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
214
215            let color = palette_colors.and_then(|c| c.get(token).cloned());
216            let color_name = color.as_ref().and_then(|c| describe_color(c));
217
218            TokenUsage { token: token.clone(), count, percentage, color, color_name }
219        })
220        .collect();
221
222    // Sort by frequency (descending)
223    tokens.sort_by(|a, b| b.count.cmp(&a.count));
224
225    // Determine palette reference
226    let palette_ref = match &sprite.palette {
227        PaletteRef::Named(name) => name.clone(),
228        PaletteRef::Inline(_) => "inline".to_string(),
229    };
230
231    SpriteExplanation {
232        name: sprite.name.clone(),
233        width,
234        height,
235        total_cells,
236        palette_ref,
237        tokens,
238        transparent_count,
239        transparency_ratio,
240        consistent_rows,
241        issues,
242    }
243}
244
245/// Analyze a palette and produce an explanation
246pub fn explain_palette(name: &str, colors: &HashMap<String, String>) -> PaletteExplanation {
247    let mut color_list: Vec<(String, String, Option<String>)> = colors
248        .iter()
249        .map(|(token, color)| {
250            let name = describe_color(color);
251            (token.clone(), color.clone(), name)
252        })
253        .collect();
254
255    // Sort alphabetically by token
256    color_list.sort_by(|a, b| a.0.cmp(&b.0));
257
258    PaletteExplanation {
259        name: name.to_string(),
260        color_count: colors.len(),
261        colors: color_list,
262        is_builtin: false,
263    }
264}
265
266/// Explain an animation
267pub fn explain_animation(animation: &Animation) -> AnimationExplanation {
268    AnimationExplanation {
269        name: animation.name.clone(),
270        frames: animation.frames.clone(),
271        frame_count: animation.frames.len(),
272        duration_ms: animation.duration_ms(),
273        loops: animation.loops(),
274    }
275}
276
277/// Explain a composition
278pub fn explain_composition(composition: &Composition) -> CompositionExplanation {
279    CompositionExplanation {
280        name: composition.name.clone(),
281        base: composition.base.clone(),
282        size: composition.size,
283        cell_size: composition.cell_size(),
284        sprite_count: composition.sprites.len(),
285        layer_count: composition.layers.len(),
286    }
287}
288
289/// Explain a variant
290pub fn explain_variant(variant: &Variant) -> VariantExplanation {
291    let overrides: Vec<(String, String)> =
292        variant.palette.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
293
294    VariantExplanation {
295        name: variant.name.clone(),
296        base: variant.base.clone(),
297        override_count: variant.palette.len(),
298        overrides,
299    }
300}
301
302/// Explain a particle system
303pub fn explain_particle(particle: &Particle) -> ParticleExplanation {
304    ParticleExplanation {
305        name: particle.name.clone(),
306        sprite: particle.sprite.clone(),
307        rate: particle.emitter.rate,
308        lifetime: particle.emitter.lifetime,
309        has_gravity: particle.emitter.gravity.is_some(),
310        has_fade: particle.emitter.fade.unwrap_or(false),
311    }
312}
313
314/// Explain a user-defined transform
315pub fn explain_transform(transform: &crate::models::TransformDef) -> TransformExplanation {
316    let transform_type = if transform.generates_animation() {
317        "keyframe animation".to_string()
318    } else if transform.is_cycling() {
319        "cycling".to_string()
320    } else if transform.is_simple() {
321        "sequence".to_string()
322    } else if transform.compose.is_some() {
323        "parallel composition".to_string()
324    } else {
325        "custom".to_string()
326    };
327
328    TransformExplanation {
329        name: transform.name.clone(),
330        is_parameterized: transform.is_parameterized(),
331        params: transform.params.clone().unwrap_or_default(),
332        generates_animation: transform.generates_animation(),
333        frame_count: transform.frames,
334        transform_type,
335    }
336}
337
338/// Explain any TtpObject
339pub fn explain_object(
340    obj: &TtpObject,
341    palette_colors: Option<&HashMap<String, String>>,
342) -> Explanation {
343    match obj {
344        TtpObject::Sprite(sprite) => Explanation::Sprite(explain_sprite(sprite, palette_colors)),
345        TtpObject::Palette(palette) => {
346            Explanation::Palette(explain_palette(&palette.name, &palette.colors))
347        }
348        TtpObject::Animation(anim) => Explanation::Animation(explain_animation(anim)),
349        TtpObject::Composition(comp) => Explanation::Composition(explain_composition(comp)),
350        TtpObject::Variant(variant) => Explanation::Variant(explain_variant(variant)),
351        TtpObject::Particle(particle) => Explanation::Particle(explain_particle(particle)),
352        TtpObject::Transform(transform) => Explanation::Transform(explain_transform(transform)),
353    }
354}
355
356/// Describe a hex color in human-readable terms
357pub fn describe_color(hex: &str) -> Option<String> {
358    // Parse the color
359    let rgba = parse_color(hex).ok()?;
360
361    // Check for transparency
362    if rgba[3] == 0 {
363        return Some("transparent".to_string());
364    }
365
366    let r = rgba[0];
367    let g = rgba[1];
368    let b = rgba[2];
369
370    // Check for grayscale
371    if r == g && g == b {
372        return Some(
373            match r {
374                0 => "black",
375                255 => "white",
376                0..=63 => "dark gray",
377                64..=127 => "gray",
378                128..=191 => "light gray",
379                192..=254 => "very light gray",
380            }
381            .to_string(),
382        );
383    }
384
385    // Determine dominant color(s)
386    let max = r.max(g).max(b);
387    let min = r.min(g).min(b);
388
389    // Calculate hue
390    let hue = if max == min {
391        0.0 // gray
392    } else {
393        let delta = (max - min) as f64;
394        let h = if max == r {
395            ((g as f64 - b as f64) / delta) % 6.0
396        } else if max == g {
397            ((b as f64 - r as f64) / delta) + 2.0
398        } else {
399            ((r as f64 - g as f64) / delta) + 4.0
400        };
401        h * 60.0
402    };
403
404    // Normalize hue to 0-360
405    let hue = if hue < 0.0 { hue + 360.0 } else { hue };
406
407    // Calculate saturation and lightness
408    let lightness = (max as f64 + min as f64) / 510.0; // 0-1
409    let saturation = if max == min {
410        0.0
411    } else if lightness <= 0.5 {
412        (max - min) as f64 / (max + min) as f64
413    } else {
414        (max - min) as f64 / (510.0 - max as f64 - min as f64)
415    };
416
417    // Map hue to color name
418    let base_color = match hue as u32 {
419        0..=14 | 346..=360 => "red",
420        15..=44 => "orange",
421        45..=74 => "yellow",
422        75..=154 => "green",
423        155..=184 => "cyan",
424        185..=254 => "blue",
425        255..=284 => "purple",
426        285..=345 => "magenta",
427        _ => "red",
428    };
429
430    // Add modifiers based on saturation and lightness
431    let modifier = if saturation < 0.2 {
432        "grayish "
433    } else if lightness < 0.2 {
434        "dark "
435    } else if lightness > 0.8 {
436        "light "
437    } else if saturation > 0.8 && lightness > 0.4 && lightness < 0.6 {
438        "bright "
439    } else {
440        ""
441    };
442
443    Some(format!("{}{}", modifier, base_color))
444}
445
446/// Format a sprite explanation as human-readable text
447pub fn format_sprite_explanation(exp: &SpriteExplanation) -> String {
448    let mut output = String::new();
449
450    // Header
451    output.push_str(&format!("Sprite: {}\n", exp.name));
452    output.push_str(&format!(
453        "Size: {}x{} pixels ({} cells)\n",
454        exp.width, exp.height, exp.total_cells
455    ));
456    output.push_str(&format!("Palette: {}\n", exp.palette_ref));
457    output.push('\n');
458
459    // Token summary
460    output.push_str("TOKENS USED\n");
461    output.push_str("-----------\n");
462
463    for usage in &exp.tokens {
464        let color_desc = match (&usage.color, &usage.color_name) {
465            (Some(hex), Some(name)) => format!(" {} ({})", hex, name),
466            (Some(hex), None) => format!(" {}", hex),
467            _ => String::new(),
468        };
469
470        output.push_str(&format!(
471            "  {:12} {:>4}x ({:>5.1}%){}",
472            usage.token, usage.count, usage.percentage, color_desc
473        ));
474        output.push('\n');
475    }
476    output.push('\n');
477
478    // Structure info
479    output.push_str("STRUCTURE\n");
480    output.push_str("---------\n");
481    output.push_str(&format!(
482        "  Transparency: {:.1}% ({} cells)\n",
483        exp.transparency_ratio, exp.transparent_count
484    ));
485    output.push_str(&format!(
486        "  Row consistency: {}\n",
487        if exp.consistent_rows { "yes" } else { "no" }
488    ));
489    output.push_str(&format!("  Unique tokens: {}\n", exp.tokens.len()));
490
491    // Issues
492    if !exp.issues.is_empty() {
493        output.push('\n');
494        output.push_str("ISSUES\n");
495        output.push_str("------\n");
496        for issue in &exp.issues {
497            output.push_str(&format!("  - {}\n", issue));
498        }
499    }
500
501    output
502}
503
504/// Format a palette explanation as human-readable text
505pub fn format_palette_explanation(exp: &PaletteExplanation) -> String {
506    let mut output = String::new();
507
508    output.push_str(&format!("Palette: {}\n", exp.name));
509    output.push_str(&format!("Colors: {}\n", exp.color_count));
510    if exp.is_builtin {
511        output.push_str("Type: built-in\n");
512    }
513    output.push('\n');
514
515    output.push_str("COLOR MAPPINGS\n");
516    output.push_str("--------------\n");
517
518    for (token, hex, name) in &exp.colors {
519        let desc = name.as_ref().map(|n| format!(" ({})", n)).unwrap_or_default();
520        output.push_str(&format!("  {:12} => {}{}\n", token, hex, desc));
521    }
522
523    output
524}
525
526/// Format an animation explanation as human-readable text
527pub fn format_animation_explanation(exp: &AnimationExplanation) -> String {
528    let mut output = String::new();
529
530    output.push_str(&format!("Animation: {}\n", exp.name));
531    output.push_str(&format!("Frames: {}\n", exp.frame_count));
532    output.push_str(&format!("Duration: {}ms per frame\n", exp.duration_ms));
533    output.push_str(&format!("Loops: {}\n", if exp.loops { "yes" } else { "no" }));
534    output.push('\n');
535
536    output.push_str("FRAME SEQUENCE\n");
537    output.push_str("--------------\n");
538    for (i, frame) in exp.frames.iter().enumerate() {
539        output.push_str(&format!("  {}: {}\n", i + 1, frame));
540    }
541
542    output
543}
544
545/// Format a composition explanation as human-readable text
546pub fn format_composition_explanation(exp: &CompositionExplanation) -> String {
547    let mut output = String::new();
548
549    output.push_str(&format!("Composition: {}\n", exp.name));
550    if let Some(base) = &exp.base {
551        output.push_str(&format!("Base sprite: {}\n", base));
552    }
553    if let Some(size) = exp.size {
554        output.push_str(&format!("Canvas size: {}x{}\n", size[0], size[1]));
555    }
556    output.push_str(&format!("Cell size: {}x{}\n", exp.cell_size[0], exp.cell_size[1]));
557    output.push_str(&format!("Sprite mappings: {}\n", exp.sprite_count));
558    output.push_str(&format!("Layers: {}\n", exp.layer_count));
559
560    output
561}
562
563/// Format a variant explanation as human-readable text
564pub fn format_variant_explanation(exp: &VariantExplanation) -> String {
565    let mut output = String::new();
566
567    output.push_str(&format!("Variant: {}\n", exp.name));
568    output.push_str(&format!("Base sprite: {}\n", exp.base));
569    output.push_str(&format!("Color overrides: {}\n", exp.override_count));
570    output.push('\n');
571
572    if !exp.overrides.is_empty() {
573        output.push_str("PALETTE OVERRIDES\n");
574        output.push_str("-----------------\n");
575        for (token, color) in &exp.overrides {
576            let desc = describe_color(color).map(|n| format!(" ({})", n)).unwrap_or_default();
577            output.push_str(&format!("  {:12} => {}{}\n", token, color, desc));
578        }
579    }
580
581    output
582}
583
584/// Format any explanation as human-readable text
585pub fn format_explanation(exp: &Explanation) -> String {
586    match exp {
587        Explanation::Sprite(s) => format_sprite_explanation(s),
588        Explanation::Palette(p) => format_palette_explanation(p),
589        Explanation::Animation(a) => format_animation_explanation(a),
590        Explanation::Composition(c) => format_composition_explanation(c),
591        Explanation::Variant(v) => format_variant_explanation(v),
592        Explanation::Particle(p) => format_particle_explanation(p),
593        Explanation::Transform(t) => format_transform_explanation(t),
594    }
595}
596
597/// Format a transform explanation as human-readable text
598fn format_transform_explanation(t: &TransformExplanation) -> String {
599    let mut lines = vec![format!("Transform: {} ({})", t.name, t.transform_type)];
600    if t.is_parameterized {
601        lines.push(format!("  Parameters: {}", t.params.join(", ")));
602    }
603    if t.generates_animation {
604        if let Some(frames) = t.frame_count {
605            lines.push(format!("  Generates {} animation frames", frames));
606        }
607    }
608    lines.join("\n")
609}
610
611/// Format a particle explanation as human-readable text
612fn format_particle_explanation(p: &ParticleExplanation) -> String {
613    let mut lines = vec![format!("Particle System: {}", p.name)];
614    lines.push(format!("  Sprite: {}", p.sprite));
615    lines.push(format!("  Rate: {} particles/frame", p.rate));
616    lines.push(format!("  Lifetime: {}-{} frames", p.lifetime[0], p.lifetime[1]));
617    if p.has_gravity {
618        lines.push("  Gravity: enabled".to_string());
619    }
620    if p.has_fade {
621        lines.push("  Fade: enabled".to_string());
622    }
623    lines.join("\n")
624}
625
626/// Resolve palette colors from a sprite's palette reference
627pub fn resolve_palette_colors(
628    palette_ref: &PaletteRef,
629    known_palettes: &HashMap<String, HashMap<String, String>>,
630) -> Option<HashMap<String, String>> {
631    match palette_ref {
632        PaletteRef::Named(name) => {
633            // Check for built-in palette
634            if name.starts_with('@') {
635                let builtin_name = name.strip_prefix('@').unwrap_or(name);
636                return palettes::get_builtin(builtin_name).map(|p| p.colors.clone());
637            }
638            // Check known palettes
639            known_palettes.get(name).cloned()
640        }
641        PaletteRef::Inline(colors) => Some(colors.clone()),
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn test_describe_color_transparent() {
651        assert_eq!(describe_color("#00000000"), Some("transparent".to_string()));
652    }
653
654    #[test]
655    fn test_describe_color_black_white() {
656        assert_eq!(describe_color("#000000"), Some("black".to_string()));
657        assert_eq!(describe_color("#FFFFFF"), Some("white".to_string()));
658    }
659
660    #[test]
661    fn test_describe_color_primary() {
662        assert!(describe_color("#FF0000").unwrap().contains("red"));
663        assert!(describe_color("#00FF00").unwrap().contains("green"));
664        assert!(describe_color("#0000FF").unwrap().contains("blue"));
665    }
666
667    #[test]
668    fn test_explain_sprite_basic() {
669        let sprite = Sprite {
670            name: "test".to_string(),
671            size: Some([2, 2]),
672            palette: PaletteRef::Inline(HashMap::from([
673                ("{_}".to_string(), "#00000000".to_string()),
674                ("{x}".to_string(), "#FF0000".to_string()),
675            ])),
676            grid: vec!["{_}{x}".to_string(), "{x}{_}".to_string()],
677            metadata: None,
678            ..Default::default()
679        };
680
681        let colors = HashMap::from([
682            ("{_}".to_string(), "#00000000".to_string()),
683            ("{x}".to_string(), "#FF0000".to_string()),
684        ]);
685
686        let exp = explain_sprite(&sprite, Some(&colors));
687
688        assert_eq!(exp.name, "test");
689        assert_eq!(exp.width, 2);
690        assert_eq!(exp.height, 2);
691        assert_eq!(exp.total_cells, 4);
692        assert_eq!(exp.transparent_count, 2);
693        assert!((exp.transparency_ratio - 50.0).abs() < 0.01);
694        assert!(exp.consistent_rows);
695        assert!(exp.issues.is_empty());
696    }
697
698    #[test]
699    fn test_explain_sprite_inconsistent_rows() {
700        let sprite = Sprite {
701            name: "uneven".to_string(),
702            size: None,
703            palette: PaletteRef::Named("test".to_string()),
704            grid: vec!["{a}{b}{c}".to_string(), "{a}{b}".to_string()],
705            metadata: None,
706            ..Default::default()
707        };
708
709        let exp = explain_sprite(&sprite, None);
710
711        assert!(!exp.consistent_rows);
712        assert!(!exp.issues.is_empty());
713    }
714
715    #[test]
716    fn test_explain_palette_basic() {
717        let colors = HashMap::from([
718            ("{_}".to_string(), "#00000000".to_string()),
719            ("{x}".to_string(), "#FF0000".to_string()),
720        ]);
721
722        let exp = explain_palette("test", &colors);
723
724        assert_eq!(exp.name, "test");
725        assert_eq!(exp.color_count, 2);
726        assert!(!exp.is_builtin);
727    }
728
729    #[test]
730    fn test_format_sprite_explanation() {
731        let exp = SpriteExplanation {
732            name: "test".to_string(),
733            width: 8,
734            height: 8,
735            total_cells: 64,
736            palette_ref: "hero".to_string(),
737            tokens: vec![
738                TokenUsage {
739                    token: "{_}".to_string(),
740                    count: 32,
741                    percentage: 50.0,
742                    color: Some("#00000000".to_string()),
743                    color_name: Some("transparent".to_string()),
744                },
745                TokenUsage {
746                    token: "{x}".to_string(),
747                    count: 32,
748                    percentage: 50.0,
749                    color: Some("#FF0000".to_string()),
750                    color_name: Some("red".to_string()),
751                },
752            ],
753            transparent_count: 32,
754            transparency_ratio: 50.0,
755            consistent_rows: true,
756            issues: vec![],
757        };
758
759        let output = format_sprite_explanation(&exp);
760
761        assert!(output.contains("Sprite: test"));
762        assert!(output.contains("Size: 8x8"));
763        assert!(output.contains("Palette: hero"));
764        assert!(output.contains("{_}"));
765        assert!(output.contains("{x}"));
766        assert!(output.contains("transparent"));
767    }
768}