use crate::color::parse_color;
use crate::models::Sprite;
use crate::registry::ResolvedSprite;
use crate::tokenizer;
use image::{Rgba, RgbaImage};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct Warning {
pub message: String,
}
impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
const MAGENTA: Rgba<u8> = Rgba([255, 0, 255, 255]);
const TRANSPARENT: Rgba<u8> = Rgba([0, 0, 0, 0]);
pub fn render_sprite(
sprite: &Sprite,
palette: &HashMap<String, String>,
) -> (RgbaImage, Vec<Warning>) {
let mut warnings = Vec::new();
let mut parsed_rows: Vec<Vec<String>> = Vec::new();
for row in &sprite.grid {
let (tokens, row_warnings) = tokenizer::tokenize(row);
for w in row_warnings {
warnings.push(Warning::new(w.message));
}
parsed_rows.push(tokens);
}
let (width, height) = if let Some([w, h]) = sprite.size {
(w as usize, h as usize)
} else {
let max_width = parsed_rows.iter().map(|r| r.len()).max().unwrap_or(0);
let grid_height = parsed_rows.len();
if max_width == 0 || grid_height == 0 {
warnings.push(Warning::new(format!("Empty grid in sprite '{}'", sprite.name)));
return (RgbaImage::from_pixel(1, 1, TRANSPARENT), warnings);
}
(max_width, grid_height)
};
if width == 0 || height == 0 {
warnings.push(Warning::new(format!("Empty grid in sprite '{}'", sprite.name)));
return (RgbaImage::from_pixel(1, 1, TRANSPARENT), warnings);
}
let mut color_cache: HashMap<String, Rgba<u8>> = HashMap::new();
for (token, hex_color) in palette {
match parse_color(hex_color) {
Ok(rgba) => {
color_cache.insert(token.clone(), rgba);
}
Err(e) => {
warnings.push(Warning::new(format!(
"Invalid color '{}' for token {}: {}, using magenta",
hex_color, token, e
)));
color_cache.insert(token.clone(), MAGENTA);
}
}
}
let mut image = RgbaImage::new(width as u32, height as u32);
for (y, row_tokens) in parsed_rows.iter().enumerate() {
if y >= height {
warnings.push(Warning::new(format!(
"Grid has {} rows, expected {}, truncating",
parsed_rows.len(),
height
)));
break;
}
let row_len = row_tokens.len();
if row_len < width {
warnings.push(Warning::new(format!(
"Row {} has {} tokens, expected {}",
y + 1,
row_len,
width
)));
} else if row_len > width {
warnings.push(Warning::new(format!(
"Row {} has {} tokens, expected {}, truncating",
y + 1,
row_len,
width
)));
}
for (x, token) in row_tokens.iter().take(width).enumerate() {
let color = if let Some(&rgba) = color_cache.get(token) {
rgba
} else {
warnings.push(Warning::new(format!(
"Unknown token {} in sprite '{}'",
token, sprite.name
)));
color_cache.insert(token.clone(), MAGENTA);
MAGENTA
};
image.put_pixel(x as u32, y as u32, color);
}
for x in row_len..width {
image.put_pixel(x as u32, y as u32, TRANSPARENT);
}
}
if parsed_rows.len() < height {
warnings.push(Warning::new(format!(
"Grid has {} rows, expected {}, padding with transparent",
parsed_rows.len(),
height
)));
}
(image, warnings)
}
pub fn render_resolved(resolved: &ResolvedSprite) -> (RgbaImage, Vec<Warning>) {
let mut warnings = Vec::new();
let mut parsed_rows: Vec<Vec<String>> = Vec::new();
for row in &resolved.grid {
let (tokens, row_warnings) = tokenizer::tokenize(row);
for w in row_warnings {
warnings.push(Warning::new(w.message));
}
parsed_rows.push(tokens);
}
let (width, height) = if let Some([w, h]) = resolved.size {
(w as usize, h as usize)
} else {
let max_width = parsed_rows.iter().map(|r| r.len()).max().unwrap_or(0);
let grid_height = parsed_rows.len();
if max_width == 0 || grid_height == 0 {
warnings
.push(Warning::new(format!("Empty grid in sprite/variant '{}'", resolved.name)));
return (RgbaImage::from_pixel(1, 1, TRANSPARENT), warnings);
}
(max_width, grid_height)
};
if width == 0 || height == 0 {
warnings.push(Warning::new(format!("Empty grid in sprite/variant '{}'", resolved.name)));
return (RgbaImage::from_pixel(1, 1, TRANSPARENT), warnings);
}
let mut color_cache: HashMap<String, Rgba<u8>> = HashMap::new();
for (token, hex_color) in &resolved.palette {
match parse_color(hex_color) {
Ok(rgba) => {
color_cache.insert(token.clone(), rgba);
}
Err(e) => {
warnings.push(Warning::new(format!(
"Invalid color '{}' for token {}: {}, using magenta",
hex_color, token, e
)));
color_cache.insert(token.clone(), MAGENTA);
}
}
}
let mut image = RgbaImage::new(width as u32, height as u32);
for (y, row_tokens) in parsed_rows.iter().enumerate() {
if y >= height {
warnings.push(Warning::new(format!(
"Grid has {} rows, expected {}, truncating",
parsed_rows.len(),
height
)));
break;
}
let row_len = row_tokens.len();
if row_len < width {
warnings.push(Warning::new(format!(
"Row {} has {} tokens, expected {}",
y + 1,
row_len,
width
)));
} else if row_len > width {
warnings.push(Warning::new(format!(
"Row {} has {} tokens, expected {}, truncating",
y + 1,
row_len,
width
)));
}
for (x, token) in row_tokens.iter().take(width).enumerate() {
let color = if let Some(&rgba) = color_cache.get(token) {
rgba
} else {
warnings.push(Warning::new(format!(
"Unknown token {} in sprite/variant '{}'",
token, resolved.name
)));
color_cache.insert(token.clone(), MAGENTA);
MAGENTA
};
image.put_pixel(x as u32, y as u32, color);
}
for x in row_len..width {
image.put_pixel(x as u32, y as u32, TRANSPARENT);
}
}
if parsed_rows.len() < height {
warnings.push(Warning::new(format!(
"Grid has {} rows, expected {}, padding with transparent",
parsed_rows.len(),
height
)));
}
(image, warnings)
}
pub fn render_nine_slice(
source: &RgbaImage,
nine_slice: &crate::models::NineSlice,
target_width: u32,
target_height: u32,
) -> (RgbaImage, Vec<Warning>) {
let mut warnings = Vec::new();
let src_width = source.width();
let src_height = source.height();
let min_width = nine_slice.left + nine_slice.right;
let min_height = nine_slice.top + nine_slice.bottom;
if min_width > src_width {
warnings.push(Warning::new(format!(
"Nine-slice borders (left={} + right={}) exceed source width ({})",
nine_slice.left, nine_slice.right, src_width
)));
return (source.clone(), warnings);
}
if min_height > src_height {
warnings.push(Warning::new(format!(
"Nine-slice borders (top={} + bottom={}) exceed source height ({})",
nine_slice.top, nine_slice.bottom, src_height
)));
return (source.clone(), warnings);
}
if target_width < min_width {
warnings.push(Warning::new(format!(
"Target width ({}) is less than minimum nine-slice width ({})",
target_width, min_width
)));
return (source.clone(), warnings);
}
if target_height < min_height {
warnings.push(Warning::new(format!(
"Target height ({}) is less than minimum nine-slice height ({})",
target_height, min_height
)));
return (source.clone(), warnings);
}
let mut result = RgbaImage::new(target_width, target_height);
let src_center_width = src_width - nine_slice.left - nine_slice.right;
let src_center_height = src_height - nine_slice.top - nine_slice.bottom;
let target_center_width = target_width - nine_slice.left - nine_slice.right;
let target_center_height = target_height - nine_slice.top - nine_slice.bottom;
let copy_region = |result: &mut RgbaImage,
src_x: u32,
src_y: u32,
dst_x: u32,
dst_y: u32,
width: u32,
height: u32| {
for dy in 0..height {
for dx in 0..width {
let pixel = *source.get_pixel(src_x + dx, src_y + dy);
result.put_pixel(dst_x + dx, dst_y + dy, pixel);
}
}
};
let stretch_horizontal = |result: &mut RgbaImage,
src_x: u32,
src_y: u32,
src_w: u32,
src_h: u32,
dst_x: u32,
dst_y: u32,
dst_w: u32| {
if src_w == 0 || dst_w == 0 {
return;
}
for dy in 0..src_h {
for dx in 0..dst_w {
let src_dx = (dx * src_w) / dst_w;
let pixel = *source.get_pixel(src_x + src_dx, src_y + dy);
result.put_pixel(dst_x + dx, dst_y + dy, pixel);
}
}
};
let stretch_vertical = |result: &mut RgbaImage,
src_x: u32,
src_y: u32,
src_w: u32,
src_h: u32,
dst_x: u32,
dst_y: u32,
dst_h: u32| {
if src_h == 0 || dst_h == 0 {
return;
}
for dy in 0..dst_h {
let src_dy = (dy * src_h) / dst_h;
for dx in 0..src_w {
let pixel = *source.get_pixel(src_x + dx, src_y + src_dy);
result.put_pixel(dst_x + dx, dst_y + dy, pixel);
}
}
};
let stretch_both = |result: &mut RgbaImage,
src_x: u32,
src_y: u32,
src_w: u32,
src_h: u32,
dst_x: u32,
dst_y: u32,
dst_w: u32,
dst_h: u32| {
if src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0 {
return;
}
for dy in 0..dst_h {
let src_dy = (dy * src_h) / dst_h;
for dx in 0..dst_w {
let src_dx = (dx * src_w) / dst_w;
let pixel = *source.get_pixel(src_x + src_dx, src_y + src_dy);
result.put_pixel(dst_x + dx, dst_y + dy, pixel);
}
}
};
copy_region(&mut result, 0, 0, 0, 0, nine_slice.left, nine_slice.top);
copy_region(
&mut result,
src_width - nine_slice.right,
0,
target_width - nine_slice.right,
0,
nine_slice.right,
nine_slice.top,
);
copy_region(
&mut result,
0,
src_height - nine_slice.bottom,
0,
target_height - nine_slice.bottom,
nine_slice.left,
nine_slice.bottom,
);
copy_region(
&mut result,
src_width - nine_slice.right,
src_height - nine_slice.bottom,
target_width - nine_slice.right,
target_height - nine_slice.bottom,
nine_slice.right,
nine_slice.bottom,
);
stretch_horizontal(
&mut result,
nine_slice.left,
0,
src_center_width,
nine_slice.top,
nine_slice.left,
0,
target_center_width,
);
stretch_horizontal(
&mut result,
nine_slice.left,
src_height - nine_slice.bottom,
src_center_width,
nine_slice.bottom,
nine_slice.left,
target_height - nine_slice.bottom,
target_center_width,
);
stretch_vertical(
&mut result,
0,
nine_slice.top,
nine_slice.left,
src_center_height,
0,
nine_slice.top,
target_center_height,
);
stretch_vertical(
&mut result,
src_width - nine_slice.right,
nine_slice.top,
nine_slice.right,
src_center_height,
target_width - nine_slice.right,
nine_slice.top,
target_center_height,
);
stretch_both(
&mut result,
nine_slice.left,
nine_slice.top,
src_center_width,
src_center_height,
nine_slice.left,
nine_slice.top,
target_center_width,
target_center_height,
);
(result, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::PaletteRef;
use serial_test::serial;
fn make_palette(colors: &[(&str, &str)]) -> HashMap<String, String> {
colors.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn test_render_minimal_dot() {
let sprite = Sprite {
name: "dot".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec!["{x}".to_string()],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{x}", "#FF0000")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 1);
assert_eq!(image.height(), 1);
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert_eq!(*pixel, Rgba([255, 0, 0, 255]));
}
#[test]
fn test_render_simple_heart() {
let sprite = Sprite {
name: "heart".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{_}{r}{r}{_}{r}{r}{_}".to_string(),
"{r}{p}{r}{r}{p}{r}{r}".to_string(),
"{r}{r}{r}{r}{r}{r}{r}".to_string(),
"{_}{r}{r}{r}{r}{r}{_}".to_string(),
"{_}{_}{r}{r}{r}{_}{_}".to_string(),
"{_}{_}{_}{r}{_}{_}{_}".to_string(),
],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{r}", "#FF0000"), ("{p}", "#FF6B6B")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 7);
assert_eq!(image.height(), 6);
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 1), Rgba([255, 107, 107, 255]));
}
#[test]
fn test_render_with_explicit_size() {
let sprite = Sprite {
name: "sized".to_string(),
size: Some([4, 4]),
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{x}{_}{_}{x}".to_string(),
"{_}{x}{x}{_}".to_string(),
"{_}{x}{x}{_}".to_string(),
"{x}{_}{_}{x}".to_string(),
],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{x}", "#0000FF")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 4);
assert_eq!(image.height(), 4);
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(3, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_render_row_too_short() {
let sprite = Sprite {
name: "short_row".to_string(),
size: Some([4, 2]),
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{x}{x}".to_string(), "{x}{x}{x}{x}".to_string(), ],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{x}", "#FF0000")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 4);
assert_eq!(image.height(), 2);
assert!(!warnings.is_empty());
assert!(warnings
.iter()
.any(|w| w.message.contains("Row 1") && w.message.contains("2 tokens")));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(2, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(3, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 1), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_render_row_too_long() {
let sprite = Sprite {
name: "long_row".to_string(),
size: Some([2, 2]),
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{x}{x}{x}{x}{x}".to_string(), "{x}{x}".to_string(), ],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{x}", "#FF0000")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 2);
assert_eq!(image.height(), 2);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.message.contains("truncating")));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_render_unknown_token() {
let sprite = Sprite {
name: "unknown".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{x}{y}{x}".to_string(), "{x}{x}{x}".to_string(),
],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{_}", "#00000000"), ("{x}", "#FF0000")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 3);
assert_eq!(image.height(), 2);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.message.contains("Unknown token {y}")));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 255, 255])); assert_eq!(*image.get_pixel(2, 0), Rgba([255, 0, 0, 255])); }
#[test]
fn test_render_empty_grid() {
let sprite = Sprite {
name: "empty".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![],
metadata: None,
..Default::default()
};
let palette = make_palette(&[]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 1);
assert_eq!(image.height(), 1);
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 0, 0]));
assert!(warnings.iter().any(|w| w.message.contains("Empty grid")));
}
#[test]
fn test_render_invalid_color() {
let sprite = Sprite {
name: "bad_color".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec!["{x}".to_string()],
metadata: None,
..Default::default()
};
let palette = make_palette(&[("{x}", "not-a-color")]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert!(warnings.iter().any(|w| w.message.contains("Invalid color")));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 255, 255]));
}
#[test]
fn test_render_size_inference() {
let sprite = Sprite {
name: "infer".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec![
"{a}{b}{c}".to_string(),
"{d}{e}".to_string(), ],
metadata: None,
..Default::default()
};
let palette = make_palette(&[
("{a}", "#FF0000"),
("{b}", "#00FF00"),
("{c}", "#0000FF"),
("{d}", "#FFFF00"),
("{e}", "#FF00FF"),
]);
let (image, warnings) = render_sprite(&sprite, &palette);
assert_eq!(image.width(), 3);
assert_eq!(image.height(), 2);
assert!(warnings.iter().any(|w| w.message.contains("Row 2")));
}
#[test]
fn test_no_duplicate_unknown_token_warnings() {
let sprite = Sprite {
name: "dupe".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::new()),
grid: vec!["{x}{x}{x}".to_string()],
metadata: None,
..Default::default()
};
let palette = make_palette(&[]);
let (image, warnings) = render_sprite(&sprite, &palette);
let unknown_x_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("Unknown token {x}")).collect();
assert_eq!(unknown_x_warnings.len(), 1);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 255, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 255, 255]));
assert_eq!(*image.get_pixel(2, 0), Rgba([255, 0, 255, 255]));
}
#[test]
#[serial]
fn test_render_from_fixture_files() {
use crate::models::{PaletteRef, TtpObject};
use crate::parser::parse_stream;
use std::fs;
use std::io::BufReader;
let file = fs::File::open("tests/fixtures/valid/minimal_dot.jsonl").unwrap();
let result = parse_stream(BufReader::new(file));
assert_eq!(result.objects.len(), 1);
if let TtpObject::Sprite(sprite) = &result.objects[0] {
let palette = match &sprite.palette {
PaletteRef::Inline(colors) => colors.clone(),
PaletteRef::Named(_) => HashMap::new(),
};
let (image, warnings) = render_sprite(sprite, &palette);
assert_eq!(image.width(), 1);
assert_eq!(image.height(), 1);
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
} else {
panic!("Expected sprite");
}
}
#[test]
fn test_render_resolved_basic() {
use crate::registry::ResolvedSprite;
let resolved = ResolvedSprite {
name: "test".to_string(),
size: None,
grid: vec!["{r}{g}".to_string(), "{b}{r}".to_string()],
palette: HashMap::from([
("{r}".to_string(), "#FF0000".to_string()),
("{g}".to_string(), "#00FF00".to_string()),
("{b}".to_string(), "#0000FF".to_string()),
]),
warnings: vec![],
nine_slice: None,
};
let (image, warnings) = render_resolved(&resolved);
assert!(warnings.is_empty());
assert_eq!(image.width(), 2);
assert_eq!(image.height(), 2);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); assert_eq!(*image.get_pixel(1, 0), Rgba([0, 255, 0, 255])); assert_eq!(*image.get_pixel(0, 1), Rgba([0, 0, 255, 255])); assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255])); }
#[test]
fn test_render_resolved_with_explicit_size() {
use crate::registry::ResolvedSprite;
let resolved = ResolvedSprite {
name: "sized".to_string(),
size: Some([3, 3]),
grid: vec![
"{x}{x}".to_string(), "{x}{x}{x}".to_string(),
],
palette: HashMap::from([("{x}".to_string(), "#FF0000".to_string())]),
warnings: vec![],
nine_slice: None,
};
let (image, warnings) = render_resolved(&resolved);
assert!(!warnings.is_empty());
assert_eq!(image.width(), 3);
assert_eq!(image.height(), 3);
}
#[test]
fn test_render_resolved_unknown_token() {
use crate::registry::ResolvedSprite;
let resolved = ResolvedSprite {
name: "unknown".to_string(),
size: None,
grid: vec!["{x}{unknown}".to_string()],
palette: HashMap::from([
("{x}".to_string(), "#FF0000".to_string()),
]),
warnings: vec![],
nine_slice: None,
};
let (image, warnings) = render_resolved(&resolved);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.message.contains("Unknown token")));
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 0, 255, 255]));
}
#[test]
fn test_render_resolved_empty_grid() {
use crate::registry::ResolvedSprite;
let resolved = ResolvedSprite {
name: "empty".to_string(),
size: None,
grid: vec![],
palette: HashMap::new(),
warnings: vec![],
nine_slice: None,
};
let (image, warnings) = render_resolved(&resolved);
assert!(!warnings.is_empty());
assert_eq!(image.width(), 1);
assert_eq!(image.height(), 1);
}
#[test]
fn test_render_resolved_variant_scenario() {
use crate::registry::ResolvedSprite;
let resolved = ResolvedSprite {
name: "hero_red".to_string(),
size: Some([2, 2]),
grid: vec!["{_}{skin}".to_string(), "{skin}{_}".to_string()],
palette: HashMap::from([
("{_}".to_string(), "#00000000".to_string()),
("{skin}".to_string(), "#FF6666".to_string()), ]),
warnings: vec![],
nine_slice: None,
};
let (image, warnings) = render_resolved(&resolved);
assert!(warnings.is_empty());
assert_eq!(image.width(), 2);
assert_eq!(image.height(), 2);
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 102, 102, 255])); assert_eq!(*image.get_pixel(0, 1), Rgba([255, 102, 102, 255]));
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(1, 1), Rgba([0, 0, 0, 0]));
}
#[test]
#[serial]
fn test_variant_full_integration() {
use crate::models::TtpObject;
use crate::parser::parse_stream;
use crate::registry::{PaletteRegistry, SpriteRegistry};
use std::fs;
use std::io::BufReader;
let file = fs::File::open("tests/fixtures/valid/variant_basic.jsonl").unwrap();
let result = parse_stream(BufReader::new(file));
assert!(result.warnings.is_empty(), "Parse warnings: {:?}", result.warnings);
assert_eq!(result.objects.len(), 3);
let mut palette_registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
for obj in &result.objects {
match obj {
TtpObject::Palette(p) => palette_registry.register(p.clone()),
TtpObject::Sprite(s) => sprite_registry.register_sprite(s.clone()),
TtpObject::Variant(v) => sprite_registry.register_variant(v.clone()),
_ => {}
}
}
let hero = sprite_registry.resolve("hero", &palette_registry, false).unwrap();
assert_eq!(hero.name, "hero");
assert_eq!(hero.size, None);
let (hero_img, hero_warns) = render_resolved(&hero);
assert!(hero_warns.is_empty());
assert_eq!(hero_img.width(), 4);
assert_eq!(hero_img.height(), 4);
let hero_red = sprite_registry.resolve("hero_red", &palette_registry, false).unwrap();
assert_eq!(hero_red.name, "hero_red");
assert_eq!(hero_red.size, None); assert_eq!(hero_red.grid, hero.grid); let (hero_red_img, hero_red_warns) = render_resolved(&hero_red);
assert!(hero_red_warns.is_empty());
assert_eq!(*hero_red_img.get_pixel(1, 1), Rgba([255, 102, 102, 255]));
let hero_alt = sprite_registry.resolve("hero_alt", &palette_registry, false).unwrap();
assert_eq!(hero_alt.name, "hero_alt");
let (hero_alt_img, hero_alt_warns) = render_resolved(&hero_alt);
assert!(hero_alt_warns.is_empty());
assert_eq!(*hero_alt_img.get_pixel(1, 0), Rgba([255, 255, 0, 255])); assert_eq!(*hero_alt_img.get_pixel(1, 1), Rgba([102, 255, 102, 255]));
assert_eq!(*hero_img.get_pixel(1, 1), Rgba([255, 204, 153, 255]));
assert_ne!(hero_img.get_pixel(1, 1), hero_red_img.get_pixel(1, 1));
assert_ne!(hero_img.get_pixel(1, 1), hero_alt_img.get_pixel(1, 1));
assert_ne!(hero_red_img.get_pixel(1, 1), hero_alt_img.get_pixel(1, 1));
}
#[test]
fn test_variant_unknown_base_integration() {
use crate::models::Variant;
use crate::registry::{PaletteRegistry, SpriteRegistry};
let palette_registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
let ghost = Variant {
name: "ghost".to_string(),
base: "nonexistent".to_string(),
palette: HashMap::new(),
..Default::default()
};
sprite_registry.register_variant(ghost);
let result = sprite_registry.resolve("ghost", &palette_registry, true);
assert!(result.is_err());
let result = sprite_registry.resolve("ghost", &palette_registry, false).unwrap();
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].message.contains("nonexistent"));
let (img, warns) = render_resolved(&result);
assert!(!warns.is_empty()); assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
#[test]
fn test_nine_slice_basic() {
use crate::models::NineSlice;
let mut source = RgbaImage::new(12, 12);
let red = Rgba([255, 0, 0, 255]);
let green = Rgba([0, 255, 0, 255]);
let blue = Rgba([0, 0, 255, 255]);
let yellow = Rgba([255, 255, 0, 255]);
for y in 0..12u32 {
for x in 0..12u32 {
let is_left = x < 4;
let is_right = x >= 8;
let is_top = y < 4;
let is_bottom = y >= 8;
let color = match (is_left || is_right, is_top || is_bottom) {
(true, true) => red, (false, true) => green, (true, false) => blue, (false, false) => yellow, };
source.put_pixel(x, y, color);
}
}
let nine_slice = NineSlice { left: 4, right: 4, top: 4, bottom: 4 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 20, 16);
assert!(warnings.is_empty());
assert_eq!(result.width(), 20);
assert_eq!(result.height(), 16);
assert_eq!(*result.get_pixel(0, 0), red);
assert_eq!(*result.get_pixel(3, 3), red);
assert_eq!(*result.get_pixel(16, 0), red);
assert_eq!(*result.get_pixel(19, 3), red);
assert_eq!(*result.get_pixel(0, 12), red);
assert_eq!(*result.get_pixel(3, 15), red);
assert_eq!(*result.get_pixel(16, 12), red);
assert_eq!(*result.get_pixel(19, 15), red);
assert_eq!(*result.get_pixel(4, 0), green);
assert_eq!(*result.get_pixel(8, 2), green);
assert_eq!(*result.get_pixel(15, 3), green);
assert_eq!(*result.get_pixel(8, 8), yellow);
}
#[test]
fn test_nine_slice_same_size() {
use crate::models::NineSlice;
let mut source = RgbaImage::new(8, 8);
let blue = Rgba([0, 0, 255, 255]);
for y in 0..8u32 {
for x in 0..8u32 {
source.put_pixel(x, y, blue);
}
}
let nine_slice = NineSlice { left: 2, right: 2, top: 2, bottom: 2 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 8, 8);
assert!(warnings.is_empty());
assert_eq!(result.width(), 8);
assert_eq!(result.height(), 8);
for y in 0..8u32 {
for x in 0..8u32 {
assert_eq!(*result.get_pixel(x, y), blue);
}
}
}
#[test]
fn test_nine_slice_invalid_borders_too_wide() {
use crate::models::NineSlice;
let source = RgbaImage::new(8, 8);
let nine_slice = NineSlice { left: 5, right: 5, top: 2, bottom: 2 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 16, 16);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("exceed source width"));
assert_eq!(result.width(), 8);
assert_eq!(result.height(), 8);
}
#[test]
fn test_nine_slice_invalid_borders_too_tall() {
use crate::models::NineSlice;
let source = RgbaImage::new(8, 8);
let nine_slice = NineSlice { left: 2, right: 2, top: 5, bottom: 5 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 16, 16);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("exceed source height"));
assert_eq!(result.width(), 8);
assert_eq!(result.height(), 8);
}
#[test]
fn test_nine_slice_target_too_small() {
use crate::models::NineSlice;
let source = RgbaImage::new(12, 12);
let nine_slice = NineSlice { left: 4, right: 4, top: 4, bottom: 4 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 6, 12);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Target width"));
assert_eq!(result.width(), 12);
}
#[test]
fn test_nine_slice_shrink() {
use crate::models::NineSlice;
let mut source = RgbaImage::new(16, 16);
let white = Rgba([255, 255, 255, 255]);
for y in 0..16u32 {
for x in 0..16u32 {
source.put_pixel(x, y, white);
}
}
let nine_slice = NineSlice { left: 4, right: 4, top: 4, bottom: 4 };
let (result, warnings) = render_nine_slice(&source, &nine_slice, 10, 10);
assert!(warnings.is_empty());
assert_eq!(result.width(), 10);
assert_eq!(result.height(), 10);
}
}