use crate::models::{Composition, VarOr};
use crate::registry::CompositionRegistry;
use crate::variables::VariableRegistry;
use image::{Rgba, RgbaImage};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Default, Clone)]
pub struct RenderContext {
composition_cache: HashMap<String, RgbaImage>,
render_stack: Vec<String>,
}
impl RenderContext {
pub fn new() -> Self {
Self { composition_cache: HashMap::new(), render_stack: Vec::new() }
}
pub fn get_cached(&self, name: &str) -> Option<&RgbaImage> {
self.composition_cache.get(name)
}
pub fn cache(&mut self, name: String, image: RgbaImage) {
self.composition_cache.insert(name, image);
}
pub fn is_cached(&self, name: &str) -> bool {
self.composition_cache.contains_key(name)
}
pub fn clear(&mut self) {
self.composition_cache.clear();
}
pub fn len(&self) -> usize {
self.composition_cache.len()
}
pub fn is_empty(&self) -> bool {
self.composition_cache.is_empty() && self.render_stack.is_empty()
}
pub fn push(&mut self, name: impl Into<String>) -> Result<(), CompositionError> {
let name = name.into();
if self.render_stack.contains(&name) {
let mut cycle_path: Vec<String> =
self.render_stack.iter().skip_while(|n| *n != &name).cloned().collect();
cycle_path.push(name);
return Err(CompositionError::CycleDetected { cycle_path });
}
self.render_stack.push(name);
Ok(())
}
pub fn pop(&mut self) -> Option<String> {
self.render_stack.pop()
}
pub fn contains(&self, name: &str) -> bool {
self.render_stack.iter().any(|n| n == name)
}
pub fn depth(&self) -> usize {
self.render_stack.len()
}
pub fn path(&self) -> &[String] {
&self.render_stack
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BlendMode {
#[default]
Normal,
Multiply,
Screen,
Overlay,
Add,
Subtract,
Difference,
Darken,
Lighten,
}
impl BlendMode {
pub fn from_str(s: &str) -> Option<BlendMode> {
match s.to_lowercase().as_str() {
"normal" => Some(BlendMode::Normal),
"multiply" => Some(BlendMode::Multiply),
"screen" => Some(BlendMode::Screen),
"overlay" => Some(BlendMode::Overlay),
"add" | "additive" => Some(BlendMode::Add),
"subtract" | "subtractive" => Some(BlendMode::Subtract),
"difference" => Some(BlendMode::Difference),
"darken" => Some(BlendMode::Darken),
"lighten" => Some(BlendMode::Lighten),
_ => None,
}
}
fn blend_channel(&self, base: f32, blend: f32) -> f32 {
match self {
BlendMode::Normal => blend,
BlendMode::Multiply => base * blend,
BlendMode::Screen => 1.0 - (1.0 - base) * (1.0 - blend),
BlendMode::Overlay => {
if base < 0.5 {
2.0 * base * blend
} else {
1.0 - 2.0 * (1.0 - base) * (1.0 - blend)
}
}
BlendMode::Add => (base + blend).min(1.0),
BlendMode::Subtract => (base - blend).max(0.0),
BlendMode::Difference => (base - blend).abs(),
BlendMode::Darken => base.min(blend),
BlendMode::Lighten => base.max(blend),
}
}
}
pub fn resolve_blend_mode(
blend: Option<&str>,
registry: Option<&VariableRegistry>,
) -> (BlendMode, Option<Warning>) {
let Some(blend_str) = blend else {
return (BlendMode::Normal, None);
};
let resolved = if blend_str.contains("var(") {
if let Some(reg) = registry {
match reg.resolve(blend_str) {
Ok(resolved) => resolved,
Err(e) => {
return (
BlendMode::Normal,
Some(Warning::new(format!(
"Failed to resolve blend mode variable '{}': {}, using normal",
blend_str, e
))),
);
}
}
} else {
return (
BlendMode::Normal,
Some(Warning::new(format!(
"Blend mode '{}' contains var() but no variable registry provided, using normal",
blend_str
))),
);
}
} else {
blend_str.to_string()
};
match BlendMode::from_str(&resolved) {
Some(mode) => (mode, None),
None => (
BlendMode::Normal,
Some(Warning::new(format!("Unknown blend mode '{}', using normal", resolved))),
),
}
}
pub fn resolve_opacity(
opacity: Option<&VarOr<f64>>,
registry: Option<&VariableRegistry>,
) -> (f64, Option<Warning>) {
let Some(opacity_val) = opacity else {
return (1.0, None);
};
match opacity_val {
VarOr::Value(v) => (*v, None),
VarOr::Var(var_str) => {
if let Some(reg) = registry {
match reg.resolve(var_str) {
Ok(resolved) => {
match resolved.trim().parse::<f64>() {
Ok(v) => (v.clamp(0.0, 1.0), None),
Err(_) => (
1.0,
Some(Warning::new(format!(
"Opacity variable '{}' resolved to '{}' which is not a valid number, using 1.0",
var_str, resolved
))),
),
}
}
Err(e) => (
1.0,
Some(Warning::new(format!(
"Failed to resolve opacity variable '{}': {}, using 1.0",
var_str, e
))),
),
}
} else {
(
1.0,
Some(Warning::new(format!(
"Opacity '{}' contains var() but no variable registry provided, using 1.0",
var_str
))),
)
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Warning {
pub message: String,
}
impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum CompositionError {
#[error("Sprite '{sprite_name}' ({sprite_w}x{sprite_h}) exceeds cell size ({cell_w}x{cell_h}) in composition '{composition_name}'", sprite_w = sprite_size.0, sprite_h = sprite_size.1, cell_w = cell_size.0, cell_h = cell_size.1)]
SizeMismatch {
sprite_name: String,
sprite_size: (u32, u32),
cell_size: (u32, u32),
composition_name: String,
},
#[error("Size ({size_w}x{size_h}) is not divisible by cell_size ({cell_w}x{cell_h}) in composition '{composition_name}'", size_w = size.0, size_h = size.1, cell_w = cell_size.0, cell_h = cell_size.1)]
SizeNotDivisible { size: (u32, u32), cell_size: (u32, u32), composition_name: String },
#[error("Map dimensions ({actual_w}x{actual_h}) don't match expected grid size ({expected_w}x{expected_h}) for {layer_desc} in composition '{composition_name}'", actual_w = actual_dimensions.0, actual_h = actual_dimensions.1, expected_w = expected_dimensions.0, expected_h = expected_dimensions.1, layer_desc = layer_name.as_ref().map(|n| format!("layer '{}'", n)).unwrap_or_else(|| "unnamed layer".to_string()))]
MapDimensionMismatch {
layer_name: Option<String>,
actual_dimensions: (usize, usize),
expected_dimensions: (u32, u32),
composition_name: String,
},
#[error("Cycle detected in composition rendering: {}", cycle_path.join(" -> "))]
CycleDetected {
cycle_path: Vec<String>,
},
}
pub fn render_composition(
comp: &Composition,
sprites: &HashMap<String, RgbaImage>,
strict: bool,
variables: Option<&VariableRegistry>,
) -> Result<(RgbaImage, Vec<Warning>), CompositionError> {
let mut warnings = Vec::new();
let cell_size = comp.cell_size.unwrap_or([1, 1]);
let base_sprite = if let Some(ref base_name) = comp.base {
match sprites.get(base_name) {
Some(img) => Some(img),
None => {
warnings.push(Warning::new(format!(
"Base sprite '{}' not found for composition '{}'",
base_name, comp.name
)));
None
}
}
} else {
None
};
let (width, height) = if let Some([w, h]) = comp.size {
(w, h)
} else if let Some(base_img) = base_sprite {
(base_img.width(), base_img.height())
} else {
let (inferred_w, inferred_h) = infer_size_from_layers(&comp.layers, cell_size);
if inferred_w == 0 || inferred_h == 0 {
warnings.push(Warning::new(format!(
"Could not infer size for composition '{}', using 1x1",
comp.name
)));
(1, 1)
} else {
(inferred_w, inferred_h)
}
};
if cell_size[0] > 1 || cell_size[1] > 1 {
let width_divisible = width % cell_size[0] == 0;
let height_divisible = height % cell_size[1] == 0;
if !width_divisible || !height_divisible {
if strict {
return Err(CompositionError::SizeNotDivisible {
size: (width, height),
cell_size: (cell_size[0], cell_size[1]),
composition_name: comp.name.clone(),
});
} else {
warnings.push(Warning::new(format!(
"Size ({}x{}) is not divisible by cell_size ({}x{}) in composition '{}'",
width, height, cell_size[0], cell_size[1], comp.name
)));
}
}
}
let expected_cols = if cell_size[0] > 0 { width / cell_size[0] } else { width };
let expected_rows = if cell_size[1] > 0 { height / cell_size[1] } else { height };
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
if let Some(base_img) = base_sprite {
blit_sprite(&mut canvas, base_img, 0, 0);
}
for layer in &comp.layers {
let (blend_mode, blend_warning) = resolve_blend_mode(layer.blend.as_deref(), variables);
if let Some(w) = blend_warning {
warnings.push(w);
}
let (opacity, opacity_warning) = resolve_opacity(layer.opacity.as_ref(), variables);
if let Some(w) = opacity_warning {
warnings.push(w);
}
if let Some(ref map) = layer.map {
if cell_size[0] > 1 || cell_size[1] > 1 {
let actual_rows = map.len();
let actual_cols = map.iter().map(|r| r.chars().count()).max().unwrap_or(0);
if actual_rows != expected_rows as usize || actual_cols != expected_cols as usize {
if strict {
return Err(CompositionError::MapDimensionMismatch {
layer_name: layer.name.clone(),
actual_dimensions: (actual_cols, actual_rows),
expected_dimensions: (expected_cols, expected_rows),
composition_name: comp.name.clone(),
});
} else {
let layer_desc = layer
.name
.as_ref()
.map(|n| format!("layer '{}'", n))
.unwrap_or_else(|| "unnamed layer".to_string());
warnings.push(Warning::new(format!(
"Map dimensions ({}x{}) don't match expected grid size ({}x{}) for {} in composition '{}'",
actual_cols, actual_rows, expected_cols, expected_rows, layer_desc, comp.name
)));
}
}
}
for (row_idx, row) in map.iter().enumerate() {
for (col_idx, char_key) in row.chars().enumerate() {
let key = char_key.to_string();
let sprite_name = match comp.sprites.get(&key) {
Some(Some(name)) => name,
Some(None) => continue, None => {
warnings.push(Warning::new(format!(
"Unknown sprite key '{}' in composition '{}'",
key, comp.name
)));
continue;
}
};
let sprite_image = match sprites.get(sprite_name) {
Some(img) => img,
None => {
warnings.push(Warning::new(format!(
"Sprite '{}' not found for composition '{}'",
sprite_name, comp.name
)));
continue;
}
};
let sprite_width = sprite_image.width();
let sprite_height = sprite_image.height();
if sprite_width > cell_size[0] || sprite_height > cell_size[1] {
if strict {
return Err(CompositionError::SizeMismatch {
sprite_name: sprite_name.clone(),
sprite_size: (sprite_width, sprite_height),
cell_size: (cell_size[0], cell_size[1]),
composition_name: comp.name.clone(),
});
} else {
warnings.push(Warning::new(format!(
"Sprite '{}' ({}x{}) exceeds cell size ({}x{}) in composition '{}', anchoring from top-left",
sprite_name, sprite_width, sprite_height, cell_size[0], cell_size[1], comp.name
)));
}
}
let x = (col_idx as u32) * cell_size[0];
let y = (row_idx as u32) * cell_size[1];
blit_sprite_blended(&mut canvas, sprite_image, x, y, blend_mode, opacity);
}
}
}
}
Ok((canvas, warnings))
}
pub fn render_composition_nested(
comp: &Composition,
sprites: &HashMap<String, RgbaImage>,
composition_registry: Option<&CompositionRegistry>,
ctx: &mut RenderContext,
strict: bool,
variables: Option<&VariableRegistry>,
) -> Result<(RgbaImage, Vec<Warning>), CompositionError> {
ctx.push(&comp.name)?;
let result =
render_composition_inner(comp, sprites, composition_registry, ctx, strict, variables);
ctx.pop();
result
}
fn render_composition_inner(
comp: &Composition,
sprites: &HashMap<String, RgbaImage>,
composition_registry: Option<&CompositionRegistry>,
ctx: &mut RenderContext,
strict: bool,
variables: Option<&VariableRegistry>,
) -> Result<(RgbaImage, Vec<Warning>), CompositionError> {
let mut warnings = Vec::new();
let cell_size = comp.cell_size.unwrap_or([1, 1]);
let base_image: Option<std::borrow::Cow<'_, RgbaImage>> = if let Some(ref base_name) = comp.base
{
if let Some(img) = sprites.get(base_name) {
Some(std::borrow::Cow::Borrowed(img))
} else if let Some(reg) = composition_registry {
if let Some(nested_comp) = reg.get(base_name) {
if let Some(cached) = ctx.get_cached(base_name) {
Some(std::borrow::Cow::Owned(cached.clone()))
} else {
let (rendered, nested_warnings) = render_composition_nested(
nested_comp,
sprites,
composition_registry,
ctx,
strict,
variables,
)?;
warnings.extend(nested_warnings);
ctx.cache(base_name.to_string(), rendered.clone());
Some(std::borrow::Cow::Owned(rendered))
}
} else {
warnings.push(Warning::new(format!(
"Base '{}' not found for composition '{}'",
base_name, comp.name
)));
None
}
} else {
warnings.push(Warning::new(format!(
"Base sprite '{}' not found for composition '{}'",
base_name, comp.name
)));
None
}
} else {
None
};
let (width, height) = if let Some([w, h]) = comp.size {
(w, h)
} else if let Some(ref base_img) = base_image {
(base_img.width(), base_img.height())
} else {
let (w, h) = infer_size_from_layers(&comp.layers, cell_size);
if w == 0 || h == 0 {
(1, 1)
} else {
(w, h)
}
};
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
if let Some(ref base_img) = base_image {
blit_sprite(&mut canvas, base_img, 0, 0);
}
for layer in &comp.layers {
let (blend_mode, blend_warning) = resolve_blend_mode(layer.blend.as_deref(), variables);
if let Some(w) = blend_warning {
warnings.push(w);
}
let (opacity, opacity_warning) = resolve_opacity(layer.opacity.as_ref(), variables);
if let Some(w) = opacity_warning {
warnings.push(w);
}
if let Some(ref map) = layer.map {
for (row_idx, row) in map.iter().enumerate() {
for (col_idx, char_key) in row.chars().enumerate() {
let key = char_key.to_string();
let sprite_name = match comp.sprites.get(&key) {
Some(Some(name)) => name,
Some(None) => continue,
None => {
warnings.push(Warning::new(format!(
"Unknown sprite key '{}' in composition '{}'",
key, comp.name
)));
continue;
}
};
let sprite_image: std::borrow::Cow<'_, RgbaImage> =
if let Some(img) = sprites.get(sprite_name) {
std::borrow::Cow::Borrowed(img)
} else if let Some(reg) = composition_registry {
if let Some(nested_comp) = reg.get(sprite_name) {
if let Some(cached) = ctx.get_cached(sprite_name) {
std::borrow::Cow::Owned(cached.clone())
} else {
let (rendered, nested_warnings) = render_composition_nested(
nested_comp,
sprites,
composition_registry,
ctx,
strict,
variables,
)?;
warnings.extend(nested_warnings);
ctx.cache(sprite_name.to_string(), rendered.clone());
std::borrow::Cow::Owned(rendered)
}
} else {
warnings.push(Warning::new(format!(
"Sprite '{}' not found for composition '{}'",
sprite_name, comp.name
)));
continue;
}
} else {
warnings.push(Warning::new(format!(
"Sprite '{}' not found for composition '{}'",
sprite_name, comp.name
)));
continue;
};
let x = (col_idx as u32) * cell_size[0];
let y = (row_idx as u32) * cell_size[1];
blit_sprite_blended(&mut canvas, &sprite_image, x, y, blend_mode, opacity);
}
}
}
}
Ok((canvas, warnings))
}
fn infer_size_from_layers(
layers: &[crate::models::CompositionLayer],
cell_size: [u32; 2],
) -> (u32, u32) {
let mut max_cols = 0u32;
let mut max_rows = 0u32;
for layer in layers {
if let Some(ref map) = layer.map {
let rows = map.len() as u32;
let cols = map.iter().map(|r| r.chars().count() as u32).max().unwrap_or(0);
max_rows = max_rows.max(rows);
max_cols = max_cols.max(cols);
}
}
(max_cols * cell_size[0], max_rows * cell_size[1])
}
fn blit_sprite(canvas: &mut RgbaImage, sprite: &RgbaImage, x: u32, y: u32) {
blit_sprite_blended(canvas, sprite, x, y, BlendMode::Normal, 1.0);
}
fn blit_sprite_blended(
canvas: &mut RgbaImage,
sprite: &RgbaImage,
x: u32,
y: u32,
blend_mode: BlendMode,
opacity: f64,
) {
let canvas_width = canvas.width();
let canvas_height = canvas.height();
let opacity = opacity.clamp(0.0, 1.0) as f32;
for (sy, row) in sprite.rows().enumerate() {
let dest_y = y + sy as u32;
if dest_y >= canvas_height {
break;
}
for (sx, pixel) in row.enumerate() {
let dest_x = x + sx as u32;
if dest_x >= canvas_width {
break;
}
let src = pixel;
if src[3] == 0 {
continue;
}
let src_alpha = (src[3] as f32 / 255.0) * opacity;
if src_alpha == 0.0 {
continue;
}
let dst = canvas.get_pixel(dest_x, dest_y);
let blended = blend_pixels(src, dst, blend_mode, src_alpha);
canvas.put_pixel(dest_x, dest_y, blended);
}
}
}
fn blend_pixels(src: &Rgba<u8>, dst: &Rgba<u8>, mode: BlendMode, src_alpha: f32) -> Rgba<u8> {
let dst_alpha = dst[3] as f32 / 255.0;
let src_r = src[0] as f32 / 255.0;
let src_g = src[1] as f32 / 255.0;
let src_b = src[2] as f32 / 255.0;
let dst_r = dst[0] as f32 / 255.0;
let dst_g = dst[1] as f32 / 255.0;
let dst_b = dst[2] as f32 / 255.0;
let blended_r = mode.blend_channel(dst_r, src_r);
let blended_g = mode.blend_channel(dst_g, src_g);
let blended_b = mode.blend_channel(dst_b, src_b);
let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
if out_alpha == 0.0 {
return Rgba([0, 0, 0, 0]);
}
let composite = |blended: f32, dst: f32| -> u8 {
let result = (blended * src_alpha + dst * dst_alpha * (1.0 - src_alpha)) / out_alpha;
(result.clamp(0.0, 1.0) * 255.0).round() as u8
};
Rgba([
composite(blended_r, dst_r),
composite(blended_g, dst_g),
composite(blended_b, dst_b),
(out_alpha * 255.0).round() as u8,
])
}
#[cfg(test)]
fn alpha_blend(src: &Rgba<u8>, dst: &Rgba<u8>) -> Rgba<u8> {
let src_a = src[3] as f32 / 255.0;
let dst_a = dst[3] as f32 / 255.0;
let out_a = src_a + dst_a * (1.0 - src_a);
if out_a == 0.0 {
return Rgba([0, 0, 0, 0]);
}
let blend = |s: u8, d: u8| -> u8 {
let s_f = s as f32 / 255.0;
let d_f = d as f32 / 255.0;
let out = (s_f * src_a + d_f * dst_a * (1.0 - src_a)) / out_a;
(out * 255.0).round() as u8
};
Rgba([
blend(src[0], dst[0]),
blend(src[1], dst[1]),
blend(src[2], dst[2]),
(out_a * 255.0).round() as u8,
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Composition, CompositionLayer};
#[test]
fn test_render_empty_composition() {
let comp = Composition {
name: "empty".to_string(),
base: None,
size: Some([8, 8]),
cell_size: None,
sprites: HashMap::new(),
layers: vec![],
};
let sprites = HashMap::new();
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(image.width(), 8);
assert_eq!(image.height(), 8);
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_render_single_layer_composition() {
let comp = Composition {
name: "single_layer".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("red_pixel".to_string())),
]),
layers: vec![CompositionLayer {
name: Some("main".to_string()),
fill: None,
map: Some(vec!["X.".to_string(), ".X".to_string()]),
..Default::default()
}],
};
let mut red_sprite = RgbaImage::new(1, 1);
red_sprite.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red_pixel".to_string(), red_sprite)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
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, 0, 0, 0])); assert_eq!(*image.get_pixel(0, 1), Rgba([0, 0, 0, 0])); assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255])); }
#[test]
fn test_infer_size_from_layers() {
let layers = vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["ABC".to_string(), "DEF".to_string()]),
..Default::default()
}];
let (width, height) = infer_size_from_layers(&layers, [1, 1]);
assert_eq!(width, 3);
assert_eq!(height, 2);
let (width, height) = infer_size_from_layers(&layers, [4, 4]);
assert_eq!(width, 12);
assert_eq!(height, 8);
}
#[test]
fn test_unknown_sprite_key_warning() {
let comp = Composition {
name: "test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: None,
sprites: HashMap::new(), layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X".to_string()]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
assert!(!warnings.is_empty());
assert!(warnings[0].message.contains("Unknown sprite key"));
}
#[test]
fn test_missing_sprite_warning() {
let comp = Composition {
name: "test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: None,
sprites: HashMap::from([("X".to_string(), Some("missing_sprite".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X".to_string()]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
assert!(!warnings.is_empty());
assert!(warnings[0].message.contains("not found"));
}
#[test]
fn test_alpha_blend() {
let src = Rgba([255, 0, 0, 255]);
let dst = Rgba([0, 0, 0, 0]);
let result = alpha_blend(&src, &dst);
assert_eq!(result, Rgba([255, 0, 0, 255]));
let src = Rgba([255, 0, 0, 128]); let dst = Rgba([0, 0, 255, 255]); let result = alpha_blend(&src, &dst);
assert!(result[0] > 100); assert!(result[2] > 100); assert_eq!(result[3], 255); }
#[test]
fn test_cell_size_default() {
let comp = Composition {
name: "no_cell_size".to_string(),
base: None,
size: None,
cell_size: None, sprites: HashMap::from([("X".to_string(), Some("pixel".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["XX".to_string(), "XX".to_string()]),
..Default::default()
}],
};
let mut pixel = RgbaImage::new(1, 1);
pixel.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("pixel".to_string(), pixel)]);
let (image, _) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(image.width(), 2);
assert_eq!(image.height(), 2);
}
#[test]
fn test_render_two_layers_stack() {
let comp = Composition {
name: "two_layers".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("R".to_string(), Some("red_pixel".to_string())),
("B".to_string(), Some("blue_pixel".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("bottom".to_string()),
fill: None,
map: Some(vec!["R.".to_string(), "..".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("top".to_string()),
fill: None,
map: Some(vec!["B.".to_string(), "..".to_string()]),
..Default::default()
},
],
};
let mut red_sprite = RgbaImage::new(1, 1);
red_sprite.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let mut blue_sprite = RgbaImage::new(1, 1);
blue_sprite.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
let sprites = HashMap::from([
("red_pixel".to_string(), red_sprite),
("blue_pixel".to_string(), blue_sprite),
]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 1), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(1, 1), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_render_two_layers_different_positions() {
let comp = Composition {
name: "two_layers_positions".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("R".to_string(), Some("red_pixel".to_string())),
("B".to_string(), Some("blue_pixel".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("bottom".to_string()),
fill: None,
map: Some(vec!["R.".to_string(), "..".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("top".to_string()),
fill: None,
map: Some(vec!["..".to_string(), ".B".to_string()]),
..Default::default()
},
],
};
let mut red_sprite = RgbaImage::new(1, 1);
red_sprite.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let mut blue_sprite = RgbaImage::new(1, 1);
blue_sprite.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
let sprites = HashMap::from([
("red_pixel".to_string(), red_sprite),
("blue_pixel".to_string(), blue_sprite),
]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 1), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 1), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_render_three_layers_stack() {
let comp = Composition {
name: "three_layers".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("R".to_string(), Some("red_pixel".to_string())),
("G".to_string(), Some("green_pixel".to_string())),
("B".to_string(), Some("blue_pixel".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("layer1".to_string()),
fill: None,
map: Some(vec!["RR".to_string(), "RR".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("layer2".to_string()),
fill: None,
map: Some(vec!["GG".to_string(), "..".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("layer3".to_string()),
fill: None,
map: Some(vec!["B.".to_string(), "..".to_string()]),
..Default::default()
},
],
};
let mut red_sprite = RgbaImage::new(1, 1);
red_sprite.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let mut green_sprite = RgbaImage::new(1, 1);
green_sprite.put_pixel(0, 0, Rgba([0, 255, 0, 255]));
let mut blue_sprite = RgbaImage::new(1, 1);
blue_sprite.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
let sprites = HashMap::from([
("red_pixel".to_string(), red_sprite),
("green_pixel".to_string(), green_sprite),
("blue_pixel".to_string(), blue_sprite),
]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(1, 0), Rgba([0, 255, 0, 255]));
assert_eq!(*image.get_pixel(0, 1), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_all_dots_layer_renders_nothing() {
let comp = Composition {
name: "dots_layer".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("R".to_string(), Some("red_pixel".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("background".to_string()),
fill: None,
map: Some(vec!["RR".to_string(), "RR".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("empty".to_string()),
fill: None,
map: Some(vec!["..".to_string(), "..".to_string()]),
..Default::default()
},
],
};
let mut red_sprite = RgbaImage::new(1, 1);
red_sprite.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red_pixel".to_string(), red_sprite)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
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(0, 1), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_sprite_fits_cell_no_warning() {
let comp = Composition {
name: "exact_fit".to_string(),
base: None,
size: Some([4, 4]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("pixel".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X.".to_string(), ".X".to_string()]),
..Default::default()
}],
};
let mut pixel = RgbaImage::new(2, 2);
pixel.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
pixel.put_pixel(1, 0, Rgba([255, 0, 0, 255]));
pixel.put_pixel(0, 1, Rgba([255, 0, 0, 255]));
pixel.put_pixel(1, 1, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("pixel".to_string(), pixel)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_sprite_smaller_than_cell_no_warning() {
let comp = Composition {
name: "small_fit".to_string(),
base: None,
size: Some([4, 4]),
cell_size: Some([4, 4]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("pixel".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X".to_string()]),
..Default::default()
}],
};
let mut pixel = RgbaImage::new(2, 2);
pixel.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("pixel".to_string(), pixel)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_sprite_larger_than_cell_lenient_warning() {
let comp = Composition {
name: "oversized".to_string(),
base: None,
size: Some([8, 8]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("big_sprite".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![
"X...".to_string(),
"....".to_string(),
"....".to_string(),
"....".to_string(),
]),
..Default::default()
}],
};
let mut big_sprite = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
big_sprite.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let sprites = HashMap::from([("big_sprite".to_string(), big_sprite)]);
let result = render_composition(&comp, &sprites, false, None);
assert!(result.is_ok());
let (image, warnings) = result.unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("exceeds cell size"));
assert!(warnings[0].message.contains("big_sprite"));
assert!(warnings[0].message.contains("4x4"));
assert!(warnings[0].message.contains("2x2"));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(3, 3), Rgba([255, 0, 0, 255])); }
#[test]
fn test_sprite_larger_than_cell_strict_error() {
let comp = Composition {
name: "oversized_strict".to_string(),
base: None,
size: Some([8, 8]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("big_sprite".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![
"X...".to_string(),
"....".to_string(),
"....".to_string(),
"....".to_string(),
]),
..Default::default()
}],
};
let mut big_sprite = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
big_sprite.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let sprites = HashMap::from([("big_sprite".to_string(), big_sprite)]);
let result = render_composition(&comp, &sprites, true, None);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CompositionError::SizeMismatch {
sprite_name,
sprite_size,
cell_size,
composition_name,
} => {
assert_eq!(sprite_name, "big_sprite");
assert_eq!(sprite_size, (4, 4));
assert_eq!(cell_size, (2, 2));
assert_eq!(composition_name, "oversized_strict");
}
_ => panic!("Expected SizeMismatch error, got {:?}", err),
}
}
#[test]
fn test_large_sprite_overwrites_from_topleft() {
let comp = Composition {
name: "topleft_anchor".to_string(),
base: None,
size: Some([6, 6]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("big_sprite".to_string())),
("B".to_string(), Some("blue".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("background".to_string()),
fill: None,
map: Some(vec!["BBB".to_string(), "BBB".to_string(), "BBB".to_string()]),
..Default::default()
},
CompositionLayer {
name: Some("foreground".to_string()),
fill: None,
map: Some(vec!["X..".to_string(), "...".to_string(), "...".to_string()]),
..Default::default()
},
],
};
let mut big_sprite = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
big_sprite.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let mut blue = RgbaImage::new(2, 2);
for y in 0..2 {
for x in 0..2 {
blue.put_pixel(x, y, Rgba([0, 0, 255, 255]));
}
}
let sprites =
HashMap::from([("big_sprite".to_string(), big_sprite), ("blue".to_string(), blue)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(3, 3), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(4, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(0, 4), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(5, 5), Rgba([0, 0, 255, 255]));
}
#[test]
fn test_width_only_exceeds_cell() {
let comp = Composition {
name: "wide".to_string(),
base: None,
size: Some([8, 4]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("wide_sprite".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X...".to_string(), "....".to_string()]),
..Default::default()
}],
};
let mut wide_sprite = RgbaImage::new(4, 2);
for y in 0..2 {
for x in 0..4 {
wide_sprite.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let sprites = HashMap::from([("wide_sprite".to_string(), wide_sprite)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("4x2"));
}
#[test]
fn test_height_only_exceeds_cell() {
let comp = Composition {
name: "tall".to_string(),
base: None,
size: Some([4, 8]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("tall_sprite".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![
"X.".to_string(),
"..".to_string(),
"..".to_string(),
"..".to_string(),
]),
..Default::default()
}],
};
let mut tall_sprite = RgbaImage::new(2, 4);
for y in 0..4 {
for x in 0..2 {
tall_sprite.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let sprites = HashMap::from([("tall_sprite".to_string(), tall_sprite)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("2x4"));
}
#[test]
fn test_multiple_size_mismatches_lenient() {
let comp = Composition {
name: "multi_mismatch".to_string(),
base: None,
size: Some([8, 8]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("A".to_string(), Some("big_a".to_string())),
("B".to_string(), Some("big_b".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![
"A...".to_string(),
"....".to_string(),
"..B.".to_string(),
"....".to_string(),
]),
..Default::default()
}],
};
let mut big_a = RgbaImage::new(3, 3);
let mut big_b = RgbaImage::new(4, 4);
for y in 0..3 {
for x in 0..3 {
big_a.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
for y in 0..4 {
for x in 0..4 {
big_b.put_pixel(x, y, Rgba([0, 255, 0, 255]));
}
}
let sprites = HashMap::from([("big_a".to_string(), big_a), ("big_b".to_string(), big_b)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(warnings.len(), 2);
}
#[test]
fn test_size_mismatch_error_display() {
let err = CompositionError::SizeMismatch {
sprite_name: "test_sprite".to_string(),
sprite_size: (10, 20),
cell_size: (5, 5),
composition_name: "test_comp".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("test_sprite"));
assert!(msg.contains("10x20"));
assert!(msg.contains("5x5"));
assert!(msg.contains("test_comp"));
}
#[test]
fn test_cell_size_1x1_pixel_perfect_overlay() {
let comp = Composition {
name: "pixel_perfect".to_string(),
base: None,
size: Some([4, 4]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None),
("R".to_string(), Some("red".to_string())),
("G".to_string(), Some("green".to_string())),
]),
layers: vec![CompositionLayer {
name: Some("overlay".to_string()),
fill: None,
map: Some(vec![
"R.G.".to_string(),
".RG.".to_string(),
"..RG".to_string(),
"...R".to_string(),
]),
..Default::default()
}],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let mut green = RgbaImage::new(1, 1);
green.put_pixel(0, 0, Rgba([0, 255, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red), ("green".to_string(), green)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 4);
assert_eq!(image.height(), 4);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); assert_eq!(*image.get_pixel(2, 0), Rgba([0, 255, 0, 255])); assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255])); assert_eq!(*image.get_pixel(2, 1), Rgba([0, 255, 0, 255])); assert_eq!(*image.get_pixel(3, 3), Rgba([255, 0, 0, 255])); assert_eq!(*image.get_pixel(1, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 3), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_cell_size_4x4_grid_cells() {
let comp = Composition {
name: "4x4_grid".to_string(),
base: None,
size: Some([16, 16]), cell_size: Some([4, 4]),
sprites: HashMap::from([
(".".to_string(), None),
("A".to_string(), Some("tile_a".to_string())),
("B".to_string(), Some("tile_b".to_string())),
]),
layers: vec![CompositionLayer {
name: Some("tiles".to_string()),
fill: None,
map: Some(vec![
"AB..".to_string(),
"BA..".to_string(),
"....".to_string(),
"..AB".to_string(),
]),
..Default::default()
}],
};
let mut tile_a = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
tile_a.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let mut tile_b = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
tile_b.put_pixel(x, y, Rgba([0, 0, 255, 255]));
}
}
let sprites =
HashMap::from([("tile_a".to_string(), tile_a), ("tile_b".to_string(), tile_b)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 16);
assert_eq!(image.height(), 16);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(3, 3), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(4, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(7, 3), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(0, 4), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(4, 4), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(8, 12), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(12, 12), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(8, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 8), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_cell_size_16x16_tile_based_scene() {
let comp = Composition {
name: "tile_scene".to_string(),
base: None,
size: Some([48, 32]), cell_size: Some([16, 16]),
sprites: HashMap::from([
(".".to_string(), None),
("G".to_string(), Some("grass".to_string())),
("W".to_string(), Some("water".to_string())),
]),
layers: vec![CompositionLayer {
name: Some("terrain".to_string()),
fill: None,
map: Some(vec!["GGW".to_string(), "GWW".to_string()]),
..Default::default()
}],
};
let mut grass = RgbaImage::new(16, 16);
for y in 0..16 {
for x in 0..16 {
grass.put_pixel(x, y, Rgba([0, 128, 0, 255])); }
}
let mut water = RgbaImage::new(16, 16);
for y in 0..16 {
for x in 0..16 {
water.put_pixel(x, y, Rgba([0, 0, 200, 255])); }
}
let sprites = HashMap::from([("grass".to_string(), grass), ("water".to_string(), water)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 48);
assert_eq!(image.height(), 32);
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 128, 0, 255])); assert_eq!(*image.get_pixel(16, 0), Rgba([0, 128, 0, 255])); assert_eq!(*image.get_pixel(32, 0), Rgba([0, 0, 200, 255]));
assert_eq!(*image.get_pixel(0, 16), Rgba([0, 128, 0, 255])); assert_eq!(*image.get_pixel(16, 16), Rgba([0, 0, 200, 255])); assert_eq!(*image.get_pixel(32, 16), Rgba([0, 0, 200, 255]));
assert_eq!(*image.get_pixel(15, 15), Rgba([0, 128, 0, 255])); assert_eq!(*image.get_pixel(47, 31), Rgba([0, 0, 200, 255])); }
#[test]
fn test_cell_size_asymmetric() {
let comp = Composition {
name: "asymmetric".to_string(),
base: None,
size: Some([24, 12]), cell_size: Some([8, 4]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("wide_tile".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X.X".to_string(), "...".to_string(), "X.X".to_string()]),
..Default::default()
}],
};
let mut wide_tile = RgbaImage::new(8, 4);
for y in 0..4 {
for x in 0..8 {
wide_tile.put_pixel(x, y, Rgba([255, 128, 0, 255])); }
}
let sprites = HashMap::from([("wide_tile".to_string(), wide_tile)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 24);
assert_eq!(image.height(), 12);
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 128, 0, 255]));
assert_eq!(*image.get_pixel(7, 3), Rgba([255, 128, 0, 255]));
assert_eq!(*image.get_pixel(16, 0), Rgba([255, 128, 0, 255]));
assert_eq!(*image.get_pixel(23, 3), Rgba([255, 128, 0, 255]));
assert_eq!(*image.get_pixel(8, 0), Rgba([0, 0, 0, 0]));
assert_eq!(*image.get_pixel(0, 8), Rgba([255, 128, 0, 255]));
assert_eq!(*image.get_pixel(7, 11), Rgba([255, 128, 0, 255]));
}
#[test]
fn test_size_inference_from_base_sprite() {
let comp = Composition {
name: "base_inference".to_string(),
base: Some("hero".to_string()),
size: None, cell_size: Some([4, 4]),
sprites: HashMap::from([
(".".to_string(), None),
("H".to_string(), Some("hat".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["H.".to_string(), "..".to_string()]),
..Default::default()
}],
};
let mut hero = RgbaImage::new(8, 8);
for y in 0..8 {
for x in 0..8 {
hero.put_pixel(x, y, Rgba([100, 100, 100, 255])); }
}
let mut hat = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
hat.put_pixel(x, y, Rgba([255, 0, 0, 255])); }
}
let sprites = HashMap::from([("hero".to_string(), hero), ("hat".to_string(), hat)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 8);
assert_eq!(image.height(), 8);
assert_eq!(*image.get_pixel(4, 4), Rgba([100, 100, 100, 255]));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(3, 3), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_size_inference_priority_explicit_over_base() {
let comp = Composition {
name: "explicit_priority".to_string(),
base: Some("base".to_string()),
size: Some([10, 10]), cell_size: None,
sprites: HashMap::new(),
layers: vec![],
};
let mut base = RgbaImage::new(32, 32);
base.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("base".to_string(), base)]);
let (image, _) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(image.width(), 10);
assert_eq!(image.height(), 10);
}
#[test]
fn test_size_inference_priority_base_over_layers() {
let comp = Composition {
name: "base_over_layers".to_string(),
base: Some("background".to_string()),
size: None, cell_size: Some([4, 4]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("tile".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X.".to_string(), ".X".to_string()]),
..Default::default()
}],
};
let mut background = RgbaImage::new(16, 20);
for y in 0..20 {
for x in 0..16 {
background.put_pixel(x, y, Rgba([50, 50, 50, 255]));
}
}
let mut tile = RgbaImage::new(4, 4);
tile.put_pixel(0, 0, Rgba([255, 255, 0, 255]));
let sprites =
HashMap::from([("background".to_string(), background), ("tile".to_string(), tile)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(image.width(), 16);
assert_eq!(image.height(), 20);
let dim_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("don't match expected")).collect();
assert_eq!(dim_warnings.len(), 1);
}
#[test]
fn test_size_inference_from_layers_with_cell_size() {
let comp = Composition {
name: "layer_inference".to_string(),
base: None,
size: None, cell_size: Some([8, 8]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("tile".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X.X".to_string(), ".X.".to_string()]),
..Default::default()
}],
};
let mut tile = RgbaImage::new(8, 8);
tile.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("tile".to_string(), tile)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 24);
assert_eq!(image.height(), 16);
}
#[test]
fn test_missing_base_sprite_warning() {
let comp = Composition {
name: "missing_base".to_string(),
base: Some("nonexistent".to_string()),
size: None,
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("tile".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["XX".to_string()]),
..Default::default()
}],
};
let mut tile = RgbaImage::new(2, 2);
tile.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("tile".to_string(), tile)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(!warnings.is_empty());
assert!(warnings[0].message.contains("Base sprite 'nonexistent' not found"));
assert_eq!(image.width(), 4); assert_eq!(image.height(), 2); }
#[test]
fn test_base_sprite_rendered_as_background() {
let comp = Composition {
name: "base_background".to_string(),
base: Some("bg".to_string()),
size: Some([4, 4]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("X".to_string(), Some("overlay".to_string())),
]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["X.".to_string(), "..".to_string()]),
..Default::default()
}],
};
let mut bg = RgbaImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
bg.put_pixel(x, y, Rgba([0, 0, 255, 255]));
}
}
let mut overlay = RgbaImage::new(2, 2);
for y in 0..2 {
for x in 0..2 {
overlay.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let sprites = HashMap::from([("bg".to_string(), bg), ("overlay".to_string(), overlay)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 1), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(2, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(0, 2), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(3, 3), Rgba([0, 0, 255, 255]));
}
#[test]
fn test_variant_usable_in_composition() {
use crate::models::{PaletteRef, Sprite, Variant};
use crate::registry::{PaletteRegistry, SpriteRegistry};
use crate::renderer::render_resolved;
let base_sprite = Sprite {
name: "hero".to_string(),
size: None,
palette: PaletteRef::Inline(HashMap::from([
("{_}".to_string(), "#00000000".to_string()),
("{skin}".to_string(), "#FFCC99".to_string()), ])),
grid: vec!["{_}{skin}".to_string(), "{skin}{_}".to_string()],
metadata: None,
..Default::default()
};
let variant = Variant {
name: "hero_red".to_string(),
base: "hero".to_string(),
palette: HashMap::from([
("{skin}".to_string(), "#FF0000".to_string()), ]),
..Default::default()
};
let palette_registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
sprite_registry.register_sprite(base_sprite);
sprite_registry.register_variant(variant);
let hero_resolved = sprite_registry.resolve("hero", &palette_registry, false).unwrap();
let variant_resolved =
sprite_registry.resolve("hero_red", &palette_registry, false).unwrap();
let (hero_img, _) = render_resolved(&hero_resolved);
let (variant_img, _) = render_resolved(&variant_resolved);
let comp = Composition {
name: "scene".to_string(),
base: None,
size: Some([4, 4]),
cell_size: Some([2, 2]),
sprites: HashMap::from([
(".".to_string(), None),
("H".to_string(), Some("hero".to_string())),
("R".to_string(), Some("hero_red".to_string())), ]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["HR".to_string(), "RH".to_string()]),
..Default::default()
}],
};
let sprites =
HashMap::from([("hero".to_string(), hero_img), ("hero_red".to_string(), variant_img)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 4);
assert_eq!(image.height(), 4);
assert_eq!(*image.get_pixel(1, 0), Rgba([255, 204, 153, 255]));
assert_eq!(*image.get_pixel(3, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(1, 2), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(3, 2), Rgba([255, 204, 153, 255])); }
#[test]
fn test_size_divisible_by_cell_size_valid() {
let comp = Composition {
name: "valid_grid".to_string(),
base: None,
size: Some([64, 64]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![
"....".to_string(),
"....".to_string(),
"....".to_string(),
"....".to_string(),
]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
let div_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("not divisible")).collect();
assert!(div_warnings.is_empty());
}
#[test]
fn test_size_not_divisible_lenient_warning() {
let comp = Composition {
name: "invalid_width".to_string(),
base: None,
size: Some([65, 64]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["....".to_string()]),
..Default::default()
}],
};
let result = render_composition(&comp, &HashMap::new(), false, None);
assert!(result.is_ok());
let (_, warnings) = result.unwrap();
let div_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("not divisible")).collect();
assert_eq!(div_warnings.len(), 1);
assert!(div_warnings[0].message.contains("65x64"));
assert!(div_warnings[0].message.contains("16x16"));
}
#[test]
fn test_size_not_divisible_strict_error() {
let comp = Composition {
name: "invalid_height".to_string(),
base: None,
size: Some([64, 65]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["....".to_string()]),
..Default::default()
}],
};
let result = render_composition(&comp, &HashMap::new(), true, None);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CompositionError::SizeNotDivisible { size, cell_size, composition_name } => {
assert_eq!(size, (64, 65));
assert_eq!(cell_size, (16, 16));
assert_eq!(composition_name, "invalid_height");
}
_ => panic!("Expected SizeNotDivisible error"),
}
}
#[test]
fn test_map_dimensions_match_expected_grid() {
let comp = Composition {
name: "valid_map".to_string(),
base: None,
size: Some([32, 32]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: Some("terrain".to_string()),
fill: None,
map: Some(vec!["..".to_string(), "..".to_string()]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
let dim_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("don't match expected")).collect();
assert!(dim_warnings.is_empty());
}
#[test]
fn test_map_dimensions_mismatch_lenient_warning() {
let comp = Composition {
name: "map_mismatch".to_string(),
base: None,
size: Some([32, 32]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: Some("terrain".to_string()),
fill: None,
map: Some(vec!["...".to_string(), "...".to_string()]),
..Default::default()
}],
};
let result = render_composition(&comp, &HashMap::new(), false, None);
assert!(result.is_ok());
let (_, warnings) = result.unwrap();
let dim_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("don't match expected")).collect();
assert_eq!(dim_warnings.len(), 1);
assert!(dim_warnings[0].message.contains("3x2")); assert!(dim_warnings[0].message.contains("2x2")); assert!(dim_warnings[0].message.contains("layer 'terrain'"));
}
#[test]
fn test_map_dimensions_mismatch_strict_error() {
let comp = Composition {
name: "map_rows_mismatch".to_string(),
base: None,
size: Some([32, 32]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: Some("layer1".to_string()),
fill: None,
map: Some(vec!["..".to_string(), "..".to_string(), "..".to_string()]),
..Default::default()
}],
};
let result = render_composition(&comp, &HashMap::new(), true, None);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CompositionError::MapDimensionMismatch {
layer_name,
actual_dimensions,
expected_dimensions,
composition_name,
} => {
assert_eq!(layer_name, Some("layer1".to_string()));
assert_eq!(actual_dimensions, (2, 3)); assert_eq!(expected_dimensions, (2, 2));
assert_eq!(composition_name, "map_rows_mismatch");
}
_ => panic!("Expected MapDimensionMismatch error"),
}
}
#[test]
fn test_map_dimensions_unnamed_layer() {
let comp = Composition {
name: "unnamed_layer_test".to_string(),
base: None,
size: Some([32, 32]),
cell_size: Some([16, 16]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None, fill: None,
map: Some(vec!["...".to_string()]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
let dim_warnings: Vec<_> =
warnings.iter().filter(|w| w.message.contains("unnamed layer")).collect();
assert_eq!(dim_warnings.len(), 1);
}
#[test]
fn test_cell_size_1x1_no_validation() {
let comp = Composition {
name: "default_cell".to_string(),
base: None,
size: Some([65, 65]),
cell_size: Some([1, 1]),
sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![".".to_string()]), ..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
let validation_warnings: Vec<_> = warnings
.iter()
.filter(|w| {
w.message.contains("not divisible") || w.message.contains("don't match expected")
})
.collect();
assert!(validation_warnings.is_empty());
}
#[test]
fn test_cell_size_none_no_validation() {
let comp = Composition {
name: "no_cell_size".to_string(),
base: None,
size: Some([65, 65]),
cell_size: None, sprites: HashMap::from([(".".to_string(), None)]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec![".".to_string()]),
..Default::default()
}],
};
let (_, warnings) = render_composition(&comp, &HashMap::new(), false, None).unwrap();
let validation_warnings: Vec<_> = warnings
.iter()
.filter(|w| {
w.message.contains("not divisible") || w.message.contains("don't match expected")
})
.collect();
assert!(validation_warnings.is_empty());
}
#[test]
fn test_size_not_divisible_error_display() {
let err = CompositionError::SizeNotDivisible {
size: (65, 64),
cell_size: (16, 16),
composition_name: "test_comp".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("65x64"));
assert!(msg.contains("16x16"));
assert!(msg.contains("test_comp"));
assert!(msg.contains("not divisible"));
}
#[test]
fn test_map_dimension_mismatch_error_display() {
let err = CompositionError::MapDimensionMismatch {
layer_name: Some("terrain".to_string()),
actual_dimensions: (3, 2),
expected_dimensions: (2, 2),
composition_name: "test_comp".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("3x2"));
assert!(msg.contains("2x2"));
assert!(msg.contains("layer 'terrain'"));
assert!(msg.contains("test_comp"));
}
#[test]
fn test_map_dimension_mismatch_unnamed_error_display() {
let err = CompositionError::MapDimensionMismatch {
layer_name: None,
actual_dimensions: (3, 2),
expected_dimensions: (2, 2),
composition_name: "test_comp".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("unnamed layer"));
}
#[test]
fn test_blend_mode_from_str() {
assert_eq!(BlendMode::from_str("normal"), Some(BlendMode::Normal));
assert_eq!(BlendMode::from_str("multiply"), Some(BlendMode::Multiply));
assert_eq!(BlendMode::from_str("screen"), Some(BlendMode::Screen));
assert_eq!(BlendMode::from_str("overlay"), Some(BlendMode::Overlay));
assert_eq!(BlendMode::from_str("add"), Some(BlendMode::Add));
assert_eq!(BlendMode::from_str("additive"), Some(BlendMode::Add));
assert_eq!(BlendMode::from_str("subtract"), Some(BlendMode::Subtract));
assert_eq!(BlendMode::from_str("difference"), Some(BlendMode::Difference));
assert_eq!(BlendMode::from_str("darken"), Some(BlendMode::Darken));
assert_eq!(BlendMode::from_str("lighten"), Some(BlendMode::Lighten));
assert_eq!(BlendMode::from_str("NORMAL"), Some(BlendMode::Normal)); assert_eq!(BlendMode::from_str("unknown"), None);
}
#[test]
fn test_blend_mode_multiply() {
let mode = BlendMode::Multiply;
assert!((mode.blend_channel(0.5, 0.5) - 0.25).abs() < 0.01);
assert!((mode.blend_channel(1.0, 0.5) - 0.5).abs() < 0.01);
assert!((mode.blend_channel(0.0, 1.0) - 0.0).abs() < 0.01);
}
#[test]
fn test_blend_mode_screen() {
let mode = BlendMode::Screen;
assert!((mode.blend_channel(0.5, 0.5) - 0.75).abs() < 0.01);
assert!((mode.blend_channel(0.5, 1.0) - 1.0).abs() < 0.01);
}
#[test]
fn test_blend_mode_add() {
let mode = BlendMode::Add;
assert!((mode.blend_channel(0.3, 0.4) - 0.7).abs() < 0.01);
assert!((mode.blend_channel(0.8, 0.5) - 1.0).abs() < 0.01);
}
#[test]
fn test_blend_mode_subtract() {
let mode = BlendMode::Subtract;
assert!((mode.blend_channel(0.7, 0.3) - 0.4).abs() < 0.01);
assert!((mode.blend_channel(0.3, 0.7) - 0.0).abs() < 0.01);
}
#[test]
fn test_blend_mode_difference() {
let mode = BlendMode::Difference;
assert!((mode.blend_channel(0.7, 0.3) - 0.4).abs() < 0.01);
assert!((mode.blend_channel(0.3, 0.7) - 0.4).abs() < 0.01);
}
#[test]
fn test_blend_mode_darken() {
let mode = BlendMode::Darken;
assert!((mode.blend_channel(0.7, 0.3) - 0.3).abs() < 0.01);
assert!((mode.blend_channel(0.3, 0.7) - 0.3).abs() < 0.01);
}
#[test]
fn test_blend_mode_lighten() {
let mode = BlendMode::Lighten;
assert!((mode.blend_channel(0.7, 0.3) - 0.7).abs() < 0.01);
assert!((mode.blend_channel(0.3, 0.7) - 0.7).abs() < 0.01);
}
#[test]
fn test_blend_pixels_normal() {
let src = Rgba([255, 0, 0, 255]); let dst = Rgba([0, 0, 255, 255]); let result = blend_pixels(&src, &dst, BlendMode::Normal, 1.0);
assert_eq!(result, Rgba([255, 0, 0, 255]));
}
#[test]
fn test_blend_pixels_multiply() {
let src = Rgba([255, 128, 0, 255]); let dst = Rgba([255, 255, 255, 255]); let result = blend_pixels(&src, &dst, BlendMode::Multiply, 1.0);
assert_eq!(result[0], 255); assert_eq!(result[1], 128); assert_eq!(result[2], 0); }
#[test]
fn test_opacity_reduces_effect() {
let src = Rgba([255, 0, 0, 255]); let dst = Rgba([0, 0, 255, 255]); let result = blend_pixels(&src, &dst, BlendMode::Normal, 0.5);
assert!(result[0] > 100 && result[0] < 200); assert!(result[2] > 100 && result[2] < 200); }
#[test]
fn test_composition_with_multiply_blend() {
let comp = Composition {
name: "multiply_test".to_string(),
base: None,
size: Some([2, 2]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
(".".to_string(), None), ("W".to_string(), Some("white".to_string())),
("S".to_string(), Some("shadow".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("background".to_string()),
fill: None,
map: Some(vec!["WW".to_string(), "WW".to_string()]),
blend: None, opacity: None,
transform: None,
},
CompositionLayer {
name: Some("shadow".to_string()),
fill: None,
map: Some(vec!["S.".to_string(), "..".to_string()]),
blend: Some("multiply".to_string()),
opacity: None,
transform: None,
},
],
};
let mut white = RgbaImage::new(1, 1);
white.put_pixel(0, 0, Rgba([255, 255, 255, 255]));
let mut shadow = RgbaImage::new(1, 1);
shadow.put_pixel(0, 0, Rgba([128, 128, 128, 255]));
let sprites = HashMap::from([("white".to_string(), white), ("shadow".to_string(), shadow)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert!(pixel[0] < 200); assert!(pixel[1] < 200); assert!(pixel[2] < 200); assert_eq!(*image.get_pixel(1, 0), Rgba([255, 255, 255, 255]));
}
#[test]
fn test_composition_with_opacity() {
let comp = Composition {
name: "opacity_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
("B".to_string(), Some("blue".to_string())),
("R".to_string(), Some("red".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("base".to_string()),
fill: None,
map: Some(vec!["B".to_string()]),
blend: None,
opacity: None, transform: None,
},
CompositionLayer {
name: Some("overlay".to_string()),
fill: None,
map: Some(vec!["R".to_string()]),
blend: None,
opacity: Some(VarOr::Value(0.5)), transform: None,
},
],
};
let mut blue = RgbaImage::new(1, 1);
blue.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("blue".to_string(), blue), ("red".to_string(), red)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert!(pixel[0] > 100); assert!(pixel[2] > 100); assert!(pixel[0] < 255); assert!(pixel[2] < 255); }
#[test]
fn test_composition_with_add_blend() {
let comp = Composition {
name: "add_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([
("B".to_string(), Some("blue".to_string())),
("R".to_string(), Some("red".to_string())),
]),
layers: vec![
CompositionLayer {
name: Some("base".to_string()),
fill: None,
map: Some(vec!["B".to_string()]),
blend: None,
opacity: None,
transform: None,
},
CompositionLayer {
name: Some("glow".to_string()),
fill: None,
map: Some(vec!["R".to_string()]),
blend: Some("add".to_string()),
opacity: None,
transform: None,
},
],
};
let mut blue = RgbaImage::new(1, 1);
blue.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("blue".to_string(), blue), ("red".to_string(), red)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert_eq!(pixel[0], 255); assert_eq!(pixel[2], 255); }
#[test]
fn test_layer_default_blend_and_opacity() {
let comp = Composition {
name: "defaults_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([("R".to_string(), Some("red".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["R".to_string()]),
blend: None, opacity: None, transform: None,
}],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red)]);
let (image, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_unknown_blend_mode_uses_normal() {
let comp = Composition {
name: "unknown_blend".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([("R".to_string(), Some("red".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["R".to_string()]),
blend: Some("invalid_blend_mode".to_string()),
opacity: None,
transform: None,
}],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red)]);
let (image, _) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_resolve_blend_mode_with_var() {
let mut registry = VariableRegistry::new();
registry.define("--blend", "multiply");
let (mode, warning) = resolve_blend_mode(Some("var(--blend)"), Some(®istry));
assert_eq!(mode, BlendMode::Multiply);
assert!(warning.is_none());
}
#[test]
fn test_resolve_blend_mode_with_var_fallback() {
let registry = VariableRegistry::new();
let (mode, warning) = resolve_blend_mode(Some("var(--missing, screen)"), Some(®istry));
assert_eq!(mode, BlendMode::Screen);
assert!(warning.is_none());
}
#[test]
fn test_resolve_blend_mode_undefined_var() {
let registry = VariableRegistry::new();
let (mode, warning) = resolve_blend_mode(Some("var(--undefined)"), Some(®istry));
assert_eq!(mode, BlendMode::Normal);
assert!(warning.is_some());
assert!(warning.unwrap().message.contains("Failed to resolve"));
}
#[test]
fn test_resolve_blend_mode_no_registry() {
let (mode, warning) = resolve_blend_mode(Some("var(--blend)"), None);
assert_eq!(mode, BlendMode::Normal);
assert!(warning.is_some());
assert!(warning.unwrap().message.contains("no variable registry"));
}
#[test]
fn test_resolve_opacity_with_var() {
let mut registry = VariableRegistry::new();
registry.define("--opacity", "0.5");
let var_opacity = VarOr::Var("var(--opacity)".to_string());
let (opacity, warning) = resolve_opacity(Some(&var_opacity), Some(®istry));
assert!((opacity - 0.5).abs() < 0.001);
assert!(warning.is_none());
}
#[test]
fn test_resolve_opacity_with_var_fallback() {
let registry = VariableRegistry::new();
let var_opacity = VarOr::Var("var(--missing, 0.75)".to_string());
let (opacity, warning) = resolve_opacity(Some(&var_opacity), Some(®istry));
assert!((opacity - 0.75).abs() < 0.001);
assert!(warning.is_none());
}
#[test]
fn test_resolve_opacity_literal() {
let registry = VariableRegistry::new();
let literal_opacity = VarOr::Value(0.3);
let (opacity, warning) = resolve_opacity(Some(&literal_opacity), Some(®istry));
assert!((opacity - 0.3).abs() < 0.001);
assert!(warning.is_none());
}
#[test]
fn test_resolve_opacity_clamps_values() {
let mut registry = VariableRegistry::new();
registry.define("--over", "2.0");
registry.define("--under", "-0.5");
let over = VarOr::Var("var(--over)".to_string());
let (opacity, _) = resolve_opacity(Some(&over), Some(®istry));
assert!((opacity - 1.0).abs() < 0.001);
let under = VarOr::Var("var(--under)".to_string());
let (opacity, _) = resolve_opacity(Some(&under), Some(®istry));
assert!(opacity.abs() < 0.001);
}
#[test]
fn test_resolve_opacity_invalid_number() {
let mut registry = VariableRegistry::new();
registry.define("--invalid", "not-a-number");
let var_opacity = VarOr::Var("var(--invalid)".to_string());
let (opacity, warning) = resolve_opacity(Some(&var_opacity), Some(®istry));
assert!((opacity - 1.0).abs() < 0.001); assert!(warning.is_some());
assert!(warning.unwrap().message.contains("not a valid number"));
}
#[test]
fn test_resolve_opacity_no_registry() {
let var_opacity = VarOr::Var("var(--opacity)".to_string());
let (opacity, warning) = resolve_opacity(Some(&var_opacity), None);
assert!((opacity - 1.0).abs() < 0.001); assert!(warning.is_some());
assert!(warning.unwrap().message.contains("no variable registry"));
}
#[test]
fn test_composition_with_var_opacity() {
let mut registry = VariableRegistry::new();
registry.define("--layer-opacity", "0.5");
let comp = Composition {
name: "var_opacity_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([("R".to_string(), Some("red".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["R".to_string()]),
blend: None,
opacity: Some(VarOr::Var("var(--layer-opacity)".to_string())),
transform: None,
}],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red)]);
let (image, warnings) =
render_composition(&comp, &sprites, false, Some(®istry)).unwrap();
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert_eq!(pixel[0], 255); assert!(pixel[3] < 200); }
#[test]
fn test_composition_with_var_blend_mode() {
let mut registry = VariableRegistry::new();
registry.define("--layer-blend", "add");
let comp = Composition {
name: "var_blend_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([("R".to_string(), Some("red".to_string()))]),
layers: vec![
CompositionLayer {
name: Some("base".to_string()),
fill: None,
map: Some(vec!["R".to_string()]),
blend: None,
opacity: Some(VarOr::Value(1.0)),
transform: None,
},
CompositionLayer {
name: Some("top".to_string()),
fill: None,
map: Some(vec!["R".to_string()]),
blend: Some("var(--layer-blend)".to_string()),
opacity: Some(VarOr::Value(1.0)),
transform: None,
},
],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([128, 0, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red)]);
let (image, warnings) =
render_composition(&comp, &sprites, false, Some(®istry)).unwrap();
assert!(warnings.is_empty());
let pixel = image.get_pixel(0, 0);
assert_eq!(pixel[0], 255); }
#[test]
fn test_composition_var_without_registry_warns() {
let comp = Composition {
name: "no_registry_test".to_string(),
base: None,
size: Some([1, 1]),
cell_size: Some([1, 1]),
sprites: HashMap::from([("R".to_string(), Some("red".to_string()))]),
layers: vec![CompositionLayer {
name: None,
fill: None,
map: Some(vec!["R".to_string()]),
blend: Some("var(--blend)".to_string()),
opacity: Some(VarOr::Var("var(--opacity)".to_string())),
transform: None,
}],
};
let mut red = RgbaImage::new(1, 1);
red.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let sprites = HashMap::from([("red".to_string(), red)]);
let (_, warnings) = render_composition(&comp, &sprites, false, None).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings.iter().any(|w| w.message.contains("blend")));
assert!(warnings.iter().any(|w| w.message.contains("Opacity")));
}
#[test]
fn test_var_or_deserialization() {
use serde_json;
let num: VarOr<f64> = serde_json::from_str("0.5").unwrap();
assert!(matches!(num, VarOr::Value(v) if (v - 0.5).abs() < 0.001));
let var: VarOr<f64> = serde_json::from_str(r#""var(--opacity)""#).unwrap();
assert!(matches!(var, VarOr::Var(s) if s == "var(--opacity)"));
let var_fb: VarOr<f64> = serde_json::from_str(r#""var(--opacity, 0.5)""#).unwrap();
assert!(matches!(var_fb, VarOr::Var(s) if s == "var(--opacity, 0.5)"));
}
#[test]
fn test_render_context_new() {
let ctx = RenderContext::new();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
assert_eq!(ctx.depth(), 0);
assert!(ctx.path().is_empty());
}
#[test]
fn test_render_context_cache_and_get() {
let mut ctx = RenderContext::new();
let mut img = RgbaImage::new(2, 2);
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
ctx.cache("test_comp".to_string(), img);
assert!(ctx.is_cached("test_comp"));
assert_eq!(ctx.len(), 1);
let cached = ctx.get_cached("test_comp");
assert!(cached.is_some());
let cached = cached.unwrap();
assert_eq!(cached.width(), 2);
assert_eq!(cached.height(), 2);
assert_eq!(*cached.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_render_context_get_cached_not_found() {
let ctx = RenderContext::new();
assert!(ctx.get_cached("nonexistent").is_none());
assert!(!ctx.is_cached("nonexistent"));
}
#[test]
fn test_render_context_cache_overwrites() {
let mut ctx = RenderContext::new();
let mut img1 = RgbaImage::new(1, 1);
img1.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
ctx.cache("comp".to_string(), img1);
let mut img2 = RgbaImage::new(1, 1);
img2.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
ctx.cache("comp".to_string(), img2);
assert_eq!(ctx.len(), 1);
let cached = ctx.get_cached("comp").unwrap();
assert_eq!(*cached.get_pixel(0, 0), Rgba([0, 0, 255, 255]));
}
#[test]
fn test_render_context_multiple_compositions() {
let mut ctx = RenderContext::new();
let mut img1 = RgbaImage::new(1, 1);
img1.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
ctx.cache("red".to_string(), img1);
let mut img2 = RgbaImage::new(1, 1);
img2.put_pixel(0, 0, Rgba([0, 255, 0, 255]));
ctx.cache("green".to_string(), img2);
let mut img3 = RgbaImage::new(1, 1);
img3.put_pixel(0, 0, Rgba([0, 0, 255, 255]));
ctx.cache("blue".to_string(), img3);
assert_eq!(ctx.len(), 3);
assert!(ctx.is_cached("red"));
assert!(ctx.is_cached("green"));
assert!(ctx.is_cached("blue"));
assert_eq!(*ctx.get_cached("red").unwrap().get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*ctx.get_cached("green").unwrap().get_pixel(0, 0), Rgba([0, 255, 0, 255]));
assert_eq!(*ctx.get_cached("blue").unwrap().get_pixel(0, 0), Rgba([0, 0, 255, 255]));
}
#[test]
fn test_render_context_clear() {
let mut ctx = RenderContext::new();
ctx.cache("a".to_string(), RgbaImage::new(1, 1));
ctx.cache("b".to_string(), RgbaImage::new(1, 1));
assert_eq!(ctx.len(), 2);
ctx.clear();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
assert!(!ctx.is_cached("a"));
assert!(!ctx.is_cached("b"));
}
#[test]
fn test_render_context_cache_hit_avoids_rerender() {
let mut ctx = RenderContext::new();
let mut render_count = 0;
for _ in 0..2 {
if ctx.get_cached("scene").is_none() {
render_count += 1;
let mut img = RgbaImage::new(4, 4);
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
ctx.cache("scene".to_string(), img);
}
let _cached = ctx.get_cached("scene").unwrap();
}
assert_eq!(render_count, 1);
}
#[test]
fn test_render_context_push_pop_basic() {
let mut ctx = RenderContext::new();
assert!(ctx.push("A").is_ok());
assert_eq!(ctx.depth(), 1);
assert!(ctx.contains("A"));
assert!(!ctx.is_empty());
assert!(ctx.push("B").is_ok());
assert_eq!(ctx.depth(), 2);
assert!(ctx.contains("A"));
assert!(ctx.contains("B"));
assert!(ctx.push("C").is_ok());
assert_eq!(ctx.depth(), 3);
assert_eq!(ctx.path(), &["A", "B", "C"]);
assert_eq!(ctx.pop(), Some("C".to_string()));
assert_eq!(ctx.depth(), 2);
assert!(!ctx.contains("C"));
assert_eq!(ctx.pop(), Some("B".to_string()));
assert_eq!(ctx.depth(), 1);
assert_eq!(ctx.pop(), Some("A".to_string()));
assert!(ctx.is_empty());
assert_eq!(ctx.pop(), None);
}
#[test]
fn test_render_context_a_b_c_renders_ok() {
let mut ctx = RenderContext::new();
assert!(ctx.push("A").is_ok());
assert!(ctx.push("B").is_ok());
assert!(ctx.push("C").is_ok());
assert_eq!(ctx.depth(), 3);
assert_eq!(ctx.path(), &["A", "B", "C"]);
}
#[test]
fn test_render_context_cycle_a_b_a() {
let mut ctx = RenderContext::new();
assert!(ctx.push("A").is_ok());
assert!(ctx.push("B").is_ok());
let result = ctx.push("A");
assert!(result.is_err());
match result {
Err(CompositionError::CycleDetected { cycle_path }) => {
assert_eq!(cycle_path, vec!["A", "B", "A"]);
}
_ => panic!("Expected CycleDetected error"),
}
}
#[test]
fn test_render_context_self_reference_cycle() {
let mut ctx = RenderContext::new();
assert!(ctx.push("A").is_ok());
let result = ctx.push("A");
assert!(result.is_err());
match result {
Err(CompositionError::CycleDetected { cycle_path }) => {
assert_eq!(cycle_path, vec!["A", "A"]);
}
_ => panic!("Expected CycleDetected error"),
}
}
#[test]
fn test_render_context_longer_cycle() {
let mut ctx = RenderContext::new();
assert!(ctx.push("A").is_ok());
assert!(ctx.push("B").is_ok());
assert!(ctx.push("C").is_ok());
assert!(ctx.push("D").is_ok());
let result = ctx.push("B");
assert!(result.is_err());
match result {
Err(CompositionError::CycleDetected { cycle_path }) => {
assert_eq!(cycle_path, vec!["B", "C", "D", "B"]);
}
_ => panic!("Expected CycleDetected error"),
}
}
#[test]
fn test_render_context_cycle_error_message() {
let mut ctx = RenderContext::new();
ctx.push("scene").unwrap();
ctx.push("background").unwrap();
ctx.push("overlay").unwrap();
let result = ctx.push("scene");
let err = result.unwrap_err();
let message = err.to_string();
assert!(message.contains("Cycle detected"));
assert!(message.contains("scene -> background -> overlay -> scene"));
}
#[test]
fn test_render_context_reuse_after_pop() {
let mut ctx = RenderContext::new();
ctx.push("A").unwrap();
ctx.push("B").unwrap();
ctx.pop();
assert!(ctx.push("B").is_ok());
assert_eq!(ctx.path(), &["A", "B"]);
}
#[test]
fn test_render_context_default() {
let ctx: RenderContext = Default::default();
assert!(ctx.is_empty());
assert_eq!(ctx.depth(), 0);
}
#[test]
fn test_render_context_clone() {
let mut ctx = RenderContext::new();
ctx.push("A").unwrap();
ctx.push("B").unwrap();
let cloned = ctx.clone();
assert_eq!(cloned.depth(), 2);
assert_eq!(cloned.path(), &["A", "B"]);
ctx.push("C").unwrap();
assert_eq!(ctx.depth(), 3);
assert_eq!(cloned.depth(), 2);
}
mod nested {
use super::super::{
render_composition_nested, Composition, CompositionError, RenderContext,
};
use crate::registry::CompositionRegistry;
use image::{Rgba, RgbaImage};
use std::collections::HashMap;
fn make_composition(
name: &str,
sprites_map: HashMap<String, Option<String>>,
layers: Vec<Vec<String>>,
size: Option<[u32; 2]>,
) -> Composition {
Composition {
name: name.to_string(),
base: None,
size,
cell_size: Some([2, 2]),
sprites: sprites_map,
layers: layers
.into_iter()
.map(|map| crate::models::CompositionLayer {
name: None,
fill: None,
map: Some(map),
transform: None,
blend: None,
opacity: None,
})
.collect(),
}
}
fn make_sprite_image(r: u8, g: u8, b: u8) -> RgbaImage {
let mut img = RgbaImage::new(2, 2);
for pixel in img.pixels_mut() {
*pixel = Rgba([r, g, b, 255]);
}
img
}
#[test]
fn test_nested_composition_with_sprite_regression() {
let mut sprites = HashMap::new();
sprites.insert("red".to_string(), make_sprite_image(255, 0, 0));
sprites.insert("blue".to_string(), make_sprite_image(0, 0, 255));
let sprites_map: HashMap<String, Option<String>> = [
("R".to_string(), Some("red".to_string())),
("B".to_string(), Some("blue".to_string())),
(".".to_string(), None),
]
.into_iter()
.collect();
let comp = make_composition(
"main",
sprites_map,
vec![vec!["RB".to_string(), "BR".to_string()]],
Some([4, 4]),
);
let composition_registry = CompositionRegistry::new();
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (image, warnings) = result.unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 4);
assert_eq!(image.height(), 4);
assert_eq!(image.get_pixel(0, 0)[0], 255); assert_eq!(image.get_pixel(2, 0)[2], 255); }
#[test]
fn test_nested_composition_references_composition() {
let mut sprites = HashMap::new();
sprites.insert("pixel".to_string(), make_sprite_image(128, 128, 128));
let sub_sprites: HashMap<String, Option<String>> =
[("P".to_string(), Some("pixel".to_string()))].into_iter().collect();
let sub_comp =
make_composition("sub", sub_sprites, vec![vec!["P".to_string()]], Some([2, 2]));
let main_sprites: HashMap<String, Option<String>> =
[("S".to_string(), Some("sub".to_string()))].into_iter().collect();
let main_comp =
make_composition("main", main_sprites, vec![vec!["S".to_string()]], Some([2, 2]));
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(sub_comp);
composition_registry.register(main_comp.clone());
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&main_comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (image, warnings) = result.unwrap();
assert!(warnings.is_empty());
assert_eq!(image.width(), 2);
assert_eq!(image.height(), 2);
assert_eq!(*image.get_pixel(0, 0), Rgba([128, 128, 128, 255]));
}
#[test]
fn test_nested_two_level_nesting() {
let mut sprites = HashMap::new();
sprites.insert("green".to_string(), make_sprite_image(0, 255, 0));
let comp_b_sprites: HashMap<String, Option<String>> =
[("G".to_string(), Some("green".to_string()))].into_iter().collect();
let comp_b = make_composition(
"comp_b",
comp_b_sprites,
vec![vec!["G".to_string()]],
Some([2, 2]),
);
let comp_a_sprites: HashMap<String, Option<String>> =
[("B".to_string(), Some("comp_b".to_string()))].into_iter().collect();
let comp_a = make_composition(
"comp_a",
comp_a_sprites,
vec![vec!["B".to_string()]],
Some([2, 2]),
);
let main_sprites: HashMap<String, Option<String>> =
[("A".to_string(), Some("comp_a".to_string()))].into_iter().collect();
let main_comp =
make_composition("main", main_sprites, vec![vec!["A".to_string()]], Some([2, 2]));
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(comp_b);
composition_registry.register(comp_a);
composition_registry.register(main_comp.clone());
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&main_comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (image, warnings) = result.unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([0, 255, 0, 255]));
}
#[test]
fn test_nested_cycle_detection() {
let sprites = HashMap::new();
let comp_a_sprites: HashMap<String, Option<String>> =
[("B".to_string(), Some("comp_b".to_string()))].into_iter().collect();
let comp_a = make_composition(
"comp_a",
comp_a_sprites,
vec![vec!["B".to_string()]],
Some([2, 2]),
);
let comp_b_sprites: HashMap<String, Option<String>> =
[("A".to_string(), Some("comp_a".to_string()))].into_iter().collect();
let comp_b = make_composition(
"comp_b",
comp_b_sprites,
vec![vec!["A".to_string()]],
Some([2, 2]),
);
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(comp_a.clone());
composition_registry.register(comp_b);
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&comp_a,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
let message = err.to_string();
assert!(message.contains("Cycle detected"));
}
#[test]
fn test_nested_self_reference_cycle() {
let sprites = HashMap::new();
let comp_sprites: HashMap<String, Option<String>> =
[("A".to_string(), Some("self_ref".to_string()))].into_iter().collect();
let comp = make_composition(
"self_ref",
comp_sprites,
vec![vec!["A".to_string()]],
Some([2, 2]),
);
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(comp.clone());
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, CompositionError::CycleDetected { .. }));
}
#[test]
fn test_nested_cache_reuse() {
let mut sprites = HashMap::new();
sprites.insert("red".to_string(), make_sprite_image(255, 0, 0));
let sub_sprites: HashMap<String, Option<String>> =
[("R".to_string(), Some("red".to_string()))].into_iter().collect();
let sub_comp =
make_composition("sub", sub_sprites, vec![vec!["R".to_string()]], Some([2, 2]));
let main_sprites: HashMap<String, Option<String>> =
[("S".to_string(), Some("sub".to_string()))].into_iter().collect();
let main_comp = make_composition(
"main",
main_sprites,
vec![vec!["SS".to_string(), "SS".to_string()]],
Some([4, 4]),
);
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(sub_comp);
composition_registry.register(main_comp.clone());
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&main_comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (image, _) = result.unwrap();
assert!(ctx.is_cached("sub"));
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(2, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(0, 2), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(2, 2), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_nested_base_composition() {
let mut sprites = HashMap::new();
sprites.insert("blue".to_string(), make_sprite_image(0, 0, 255));
sprites.insert("red".to_string(), make_sprite_image(255, 0, 0));
let bg_sprites: HashMap<String, Option<String>> =
[("B".to_string(), Some("blue".to_string()))].into_iter().collect();
let bg_comp = make_composition(
"background",
bg_sprites,
vec![vec!["BB".to_string(), "BB".to_string()]],
Some([4, 4]),
);
let main_sprites: HashMap<String, Option<String>> =
[("R".to_string(), Some("red".to_string())), (".".to_string(), None)]
.into_iter()
.collect();
let mut main_comp = make_composition(
"main",
main_sprites,
vec![vec!["R.".to_string(), ".R".to_string()]],
Some([4, 4]),
);
main_comp.base = Some("background".to_string());
let mut composition_registry = CompositionRegistry::new();
composition_registry.register(bg_comp);
composition_registry.register(main_comp.clone());
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&main_comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (image, warnings) = result.unwrap();
assert!(warnings.is_empty());
assert_eq!(*image.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
assert_eq!(*image.get_pixel(2, 0), Rgba([0, 0, 255, 255]));
assert_eq!(*image.get_pixel(2, 2), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_nested_missing_composition_warning() {
let sprites = HashMap::new();
let main_sprites: HashMap<String, Option<String>> =
[("X".to_string(), Some("nonexistent".to_string()))].into_iter().collect();
let main_comp =
make_composition("main", main_sprites, vec![vec!["X".to_string()]], Some([2, 2]));
let composition_registry = CompositionRegistry::new();
let mut ctx = RenderContext::new();
let result = render_composition_nested(
&main_comp,
&sprites,
Some(&composition_registry),
&mut ctx,
false,
None,
);
assert!(result.is_ok());
let (_, warnings) = result.unwrap();
assert!(!warnings.is_empty());
assert!(warnings[0].message.contains("not found"));
}
}
}