use std::collections::{BTreeMap, HashMap};
use dotzuki_engine::render::painter::Painter;
use dotzuki_engine::render::{Rgba, TilePos, TileRect};
use dotzuki_renderer::layout_engine::deserialize::parse_layout;
use dotzuki_renderer::layout_engine::registry::ElementRegistry;
use dotzuki_renderer::layout_engine::renderer::render_layout;
use dotzuki_renderer::layout_engine::types::{DataContext, DataValue, RenderContext, ScreenLayout};
#[derive(Default)]
struct RecordingPainter {
glyphs: Vec<(u32, u32, char)>, text_boxes: usize,
pixel_colors: Vec<Rgba>, }
impl RecordingPainter {
fn drawn_text(&self) -> String {
let mut rows: BTreeMap<u32, BTreeMap<u32, char>> = BTreeMap::new();
for &(ty, tx, ch) in &self.glyphs {
rows.entry(ty).or_default().insert(tx, ch);
}
rows.values()
.map(|cols| cols.values().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
}
impl Painter for RecordingPainter {
fn clear(&mut self, _color: Rgba) {}
fn draw_text_box(&mut self, _rect: TileRect, _color: Rgba) {
self.text_boxes += 1;
}
fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba) {
for (i, ch) in text.chars().enumerate() {
self.draw_glyph(TilePos::new(pos.tx + i as u32, pos.ty), ch, color);
}
}
fn draw_glyph(&mut self, pos: TilePos, glyph: char, _color: Rgba) {
self.glyphs.push((pos.ty, pos.tx, glyph));
}
fn draw_pixel_rect(&mut self, _px: u32, _py: u32, _pw: u32, _ph: u32, color: Rgba) {
self.pixel_colors.push(color);
}
fn draw_gb_tile(&mut self, _pos: TilePos, _tile_id: u8, _fallback: &str, _color: Rgba) {}
}
fn compile_gui(src: &str, path: &str) -> ScreenLayout {
let tokens = dotzuki_engine_dsl::lexer::Lexer::new(src, path)
.tokenize()
.unwrap_or_else(|e| panic!("lex {path}: {e:?}"));
let (doc, errs) = dotzuki_engine_dsl::parser::Parser::new(tokens, src).parse();
assert!(errs.is_empty(), "parse {path}: {errs:?}");
let json = match doc {
Some(dotzuki_engine_dsl::ast::Document::Screen(screen)) => {
dotzuki_engine_dsl::codegen::json_ui::compile_screen(&screen)
.unwrap_or_else(|e| panic!("codegen {path}: {e}"))
}
_ => panic!("{path}: not a screen"),
};
parse_layout(&json).unwrap_or_else(|e| panic!("parse_layout {path}: {e:?}\n{json}"))
}
fn render(layout: &ScreenLayout, ctx: &DataContext) -> RecordingPainter {
let fonts: HashMap<String, ()> = HashMap::new();
let tilesets: HashMap<String, ()> = HashMap::new();
let render_ctx = RenderContext::new(&layout.screen, &layout.theme, &fonts, &tilesets);
let registry = ElementRegistry::new();
let mut painter = RecordingPainter::default();
render_layout(layout, ctx, &render_ctx, ®istry, &mut painter)
.expect("render should not error");
painter
}
#[test]
fn save_menu_draws_label_and_resolved_text() {
let src = r#"screen Save {
panel { rect = {tx: 4, ty: 0, tw: 15, th: 10} style = "default" }
text("PLAYER") { rect = {tx: 5, ty: 2, tw: 7, th: 1} }
text("{player_name}") { rect = {tx: 13, ty: 2, tw: 5, th: 1} }
text("BADGES") { rect = {tx: 5, ty: 4, tw: 7, th: 1} }
}"#;
let layout = compile_gui(src, "save.gui");
let mut ctx = DataContext::new();
ctx.set("player_name", "ASH");
let painter = render(&layout, &ctx);
let text = painter.drawn_text();
assert!(painter.text_boxes >= 1, "panel box should draw");
assert!(text.contains("PLAYER"), "static label missing; drew: {text:?}");
assert!(text.contains("BADGES"), "static label missing; drew: {text:?}");
assert!(
text.contains("ASH"),
"resolved {{player_name}} missing; drew: {text:?}"
);
}
#[test]
fn image_element_compiles_and_blits_registered_image() {
use dotzuki_renderer::layout_engine::types::{ImageData, ImageRegistry};
let src = r#"screen Pic {
image("hero") { rect = {tx: 0, ty: 0, tw: 1, th: 1} }
}"#;
let layout = compile_gui(src, "pic.gui");
let red = Rgba::new(255, 0, 0, 255);
let mut images = ImageRegistry::new();
images.insert("hero".to_string(), ImageData::new(1, 1, vec![red]));
let ctx = DataContext::new();
let fonts: HashMap<String, ()> = HashMap::new();
let tilesets: HashMap<String, ()> = HashMap::new();
let render_ctx = RenderContext::new(&layout.screen, &layout.theme, &fonts, &tilesets)
.with_images(&images);
let registry = ElementRegistry::new();
let mut painter = RecordingPainter::default();
render_layout(&layout, &ctx, &render_ctx, ®istry, &mut painter).expect("render");
assert!(
!painter.pixel_colors.is_empty(),
"image element should blit pixels"
);
assert!(
painter.pixel_colors.iter().all(|c| *c == red),
"only the registered image colour should be drawn (no placeholder gray); got {:?}",
painter.pixel_colors
);
}
#[test]
fn image_element_without_registry_draws_placeholder() {
let src = r#"screen Pic {
image("missing") { rect = {tx: 0, ty: 0, tw: 1, th: 1} }
}"#;
let layout = compile_gui(src, "pic.gui");
let ctx = DataContext::new();
let painter = render(&layout, &ctx); assert!(
!painter.pixel_colors.is_empty(),
"placeholder should draw at least one pixel rect"
);
}
#[test]
fn start_menu_list_draws_items() {
let src = r#"screen Start {
panel { rect = {tx: 10, ty: 0, tw: 10, th: 15} style = "default" }
list {
rect = {tx: 11, ty: 1, tw: 8, th: 13}
source = "{items}"
item_template = {height: 1, gap: 1}
cursor = {tile: 223, position: "left"}
max_visible = 7
}
}"#;
let layout = compile_gui(src, "start.gui");
let mut ctx = DataContext::new();
ctx.set(
"items",
vec![
DataValue::from("MONSTER"),
DataValue::from("ITEM"),
DataValue::from("SAVE"),
],
);
let text = render(&layout, &ctx).drawn_text();
assert!(text.contains("MONSTER"), "list item missing; drew: {text:?}");
assert!(text.contains("SAVE"), "list item missing; drew: {text:?}");
}
#[test]
fn at_t_labels_render_active_language() {
let src = r#"screen Options {
text(@t("TEXT SPEED", "文字速度")) { rect = {tx: 1, ty: 1, tw: 16, th: 1} }
text(@t("CANCEL", "取消")) { rect = {tx: 2, ty: 4, tw: 8, th: 1} }
}"#;
let layout = compile_gui(src, "options.gui");
let en = render(&layout, &DataContext::new()).drawn_text();
assert!(en.contains("TEXT SPEED"), "EN label missing; drew: {en:?}");
assert!(en.contains("CANCEL"), "EN label missing; drew: {en:?}");
assert!(!en.contains("文字速度"), "EN render must not show zh; drew: {en:?}");
let mut ctx_zh = DataContext::new();
ctx_zh.set("__lang", "zh");
let zh = render(&layout, &ctx_zh).drawn_text();
assert!(zh.contains("文字速度"), "ZH label missing; drew: {zh:?}");
assert!(zh.contains("取消"), "ZH label missing; drew: {zh:?}");
assert!(!zh.contains("TEXT SPEED"), "ZH render must not show en; drew: {zh:?}");
}
#[test]
fn cursor_element_grid_follows_bindings() {
let src = r#"screen Grid {
text("FIGHT") { rect = {tx: 10, ty: 14, tw: 5, th: 1} }
text("RUN") { rect = {tx: 16, ty: 16, tw: 3, th: 1} }
cursor {
rect = {tx: 9, ty: 14, tw: 1, th: 1}
col_step = 6
row_step = 2
col = "{c}"
row = "{r}"
glyph = "▶"
}
}"#;
let layout = compile_gui(src, "grid.gui");
let triangle = '\u{25B6}';
let mut ctx = DataContext::new();
ctx.set("c", 0i64);
ctx.set("r", 0i64);
let p = render(&layout, &ctx);
assert!(
p.glyphs.iter().any(|&(ty, tx, ch)| tx == 9 && ty == 14 && ch == triangle),
"cursor should be at (9,14); glyphs: {:?}",
p.glyphs
);
let mut ctx = DataContext::new();
ctx.set("c", 1i64);
ctx.set("r", 1i64);
let p = render(&layout, &ctx);
assert!(
p.glyphs.iter().any(|&(ty, tx, ch)| tx == 15 && ty == 16 && ch == triangle),
"cursor should be at (15,16); glyphs: {:?}",
p.glyphs
);
assert!(p.drawn_text().contains("FIGHT"));
}
#[test]
fn dialog_panel_draws_nested_text() {
let src = r#"screen Dialog {
panel {
rect = {tx: 0, ty: 12, tw: 20, th: 6}
style = "default"
text("{text}") {
rect = {tx: 1, ty: 13, tw: 18, th: 4}
wrap = "word"
line_spacing = 1
}
}
}"#;
let layout = compile_gui(src, "dialog.gui");
let mut ctx = DataContext::new();
ctx.set("text", "HELLO THERE");
let painter = render(&layout, &ctx);
let text = painter.drawn_text();
assert!(painter.text_boxes >= 1, "dialog box should draw");
assert!(
text.contains("HELLO"),
"nested panel text missing; drew: {text:?}"
);
}