Skip to main content

jugar_web/
render.rs

1//! Canvas2D render commands for browser execution.
2//!
3//! This module provides JSON-serializable render commands that are executed
4//! by a minimal JavaScript Canvas2D renderer. All computation happens in Rust;
5//! JavaScript only draws primitives.
6
7#![allow(clippy::module_name_repetitions)]
8
9use serde::{Deserialize, Serialize};
10
11/// A color represented as RGBA components (0.0 to 1.0).
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
13pub struct Color {
14    /// Red component (0.0 to 1.0)
15    pub r: f32,
16    /// Green component (0.0 to 1.0)
17    pub g: f32,
18    /// Blue component (0.0 to 1.0)
19    pub b: f32,
20    /// Alpha component (0.0 to 1.0)
21    pub a: f32,
22}
23
24impl Color {
25    /// Creates a new color from RGBA components.
26    #[must_use]
27    pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
28        Self { r, g, b, a }
29    }
30
31    /// Creates a color from RGBA array.
32    #[must_use]
33    pub const fn from_array(rgba: [f32; 4]) -> Self {
34        Self {
35            r: rgba[0],
36            g: rgba[1],
37            b: rgba[2],
38            a: rgba[3],
39        }
40    }
41
42    /// Converts to RGBA array.
43    #[must_use]
44    pub const fn to_array(self) -> [f32; 4] {
45        [self.r, self.g, self.b, self.a]
46    }
47
48    /// Converts to CSS rgba() string.
49    #[must_use]
50    pub fn to_css_rgba(self) -> String {
51        format!(
52            "rgba({}, {}, {}, {})",
53            (self.r * 255.0) as u8,
54            (self.g * 255.0) as u8,
55            (self.b * 255.0) as u8,
56            self.a
57        )
58    }
59
60    /// Black color.
61    pub const BLACK: Self = Self::new(0.0, 0.0, 0.0, 1.0);
62
63    /// White color.
64    pub const WHITE: Self = Self::new(1.0, 1.0, 1.0, 1.0);
65
66    /// Transparent color.
67    pub const TRANSPARENT: Self = Self::new(0.0, 0.0, 0.0, 0.0);
68
69    /// Red color.
70    pub const RED: Self = Self::new(1.0, 0.0, 0.0, 1.0);
71
72    /// Green color.
73    pub const GREEN: Self = Self::new(0.0, 1.0, 0.0, 1.0);
74
75    /// Blue color.
76    pub const BLUE: Self = Self::new(0.0, 0.0, 1.0, 1.0);
77}
78
79impl Default for Color {
80    fn default() -> Self {
81        Self::BLACK
82    }
83}
84
85impl From<[f32; 4]> for Color {
86    fn from(rgba: [f32; 4]) -> Self {
87        Self::from_array(rgba)
88    }
89}
90
91impl From<Color> for [f32; 4] {
92    fn from(color: Color) -> Self {
93        color.to_array()
94    }
95}
96
97/// Text alignment options for Canvas2D.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
99#[serde(rename_all = "lowercase")]
100pub enum TextAlign {
101    /// Left-aligned text.
102    #[default]
103    Left,
104    /// Center-aligned text.
105    Center,
106    /// Right-aligned text.
107    Right,
108}
109
110/// Text baseline options for Canvas2D.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
112#[serde(rename_all = "lowercase")]
113pub enum TextBaseline {
114    /// Top baseline.
115    Top,
116    /// Middle baseline.
117    #[default]
118    Middle,
119    /// Bottom baseline.
120    Bottom,
121    /// Alphabetic baseline.
122    Alphabetic,
123}
124
125/// Canvas2D render commands that are serialized to JSON.
126///
127/// These commands are designed to be minimal and directly map to Canvas2D API calls.
128/// The JavaScript renderer simply iterates over commands and executes them.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(tag = "type")]
131pub enum Canvas2DCommand {
132    /// Clear the entire canvas with a color.
133    Clear {
134        /// Fill color for clearing
135        color: Color,
136    },
137
138    /// Fill a rectangle.
139    FillRect {
140        /// X position
141        x: f32,
142        /// Y position
143        y: f32,
144        /// Width
145        width: f32,
146        /// Height
147        height: f32,
148        /// Fill color
149        color: Color,
150    },
151
152    /// Stroke a rectangle outline.
153    StrokeRect {
154        /// X position
155        x: f32,
156        /// Y position
157        y: f32,
158        /// Width
159        width: f32,
160        /// Height
161        height: f32,
162        /// Stroke color
163        color: Color,
164        /// Line width
165        line_width: f32,
166    },
167
168    /// Fill a circle.
169    FillCircle {
170        /// Center X
171        x: f32,
172        /// Center Y
173        y: f32,
174        /// Radius
175        radius: f32,
176        /// Fill color
177        color: Color,
178    },
179
180    /// Stroke a circle outline.
181    StrokeCircle {
182        /// Center X
183        x: f32,
184        /// Center Y
185        y: f32,
186        /// Radius
187        radius: f32,
188        /// Stroke color
189        color: Color,
190        /// Line width
191        line_width: f32,
192    },
193
194    /// Draw a line.
195    Line {
196        /// Start X
197        x1: f32,
198        /// Start Y
199        y1: f32,
200        /// End X
201        x2: f32,
202        /// End Y
203        y2: f32,
204        /// Line color
205        color: Color,
206        /// Line width
207        line_width: f32,
208    },
209
210    /// Draw text.
211    FillText {
212        /// Text content
213        text: String,
214        /// X position
215        x: f32,
216        /// Y position
217        y: f32,
218        /// CSS font string (e.g., "48px monospace")
219        font: String,
220        /// Text color
221        color: Color,
222        /// Text alignment
223        align: TextAlign,
224        /// Text baseline
225        baseline: TextBaseline,
226    },
227
228    /// Draw an image/sprite from a loaded texture.
229    DrawImage {
230        /// Texture ID (index into loaded textures array)
231        texture_id: u32,
232        /// Destination X
233        x: f32,
234        /// Destination Y
235        y: f32,
236        /// Destination width
237        width: f32,
238        /// Destination height
239        height: f32,
240    },
241
242    /// Draw a portion of an image (sprite sheet).
243    DrawImageSlice {
244        /// Texture ID
245        texture_id: u32,
246        /// Source X in texture
247        src_x: f32,
248        /// Source Y in texture
249        src_y: f32,
250        /// Source width
251        src_width: f32,
252        /// Source height
253        src_height: f32,
254        /// Destination X
255        dst_x: f32,
256        /// Destination Y
257        dst_y: f32,
258        /// Destination width
259        dst_width: f32,
260        /// Destination height
261        dst_height: f32,
262    },
263
264    /// Save the current canvas state.
265    Save,
266
267    /// Restore the previously saved canvas state.
268    Restore,
269
270    /// Translate the canvas origin.
271    Translate {
272        /// X translation
273        x: f32,
274        /// Y translation
275        y: f32,
276    },
277
278    /// Rotate the canvas.
279    Rotate {
280        /// Rotation angle in radians
281        angle: f32,
282    },
283
284    /// Scale the canvas.
285    Scale {
286        /// X scale factor
287        x: f32,
288        /// Y scale factor
289        y: f32,
290    },
291
292    /// Set global alpha (opacity).
293    SetAlpha {
294        /// Alpha value (0.0 to 1.0)
295        alpha: f32,
296    },
297}
298
299/// A frame's worth of render commands.
300#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
301pub struct RenderFrame {
302    /// The commands to execute this frame.
303    pub commands: Vec<Canvas2DCommand>,
304}
305
306impl RenderFrame {
307    /// Creates a new empty render frame.
308    #[must_use]
309    pub fn new() -> Self {
310        Self::default()
311    }
312
313    /// Creates a render frame with an initial capacity.
314    #[must_use]
315    pub fn with_capacity(capacity: usize) -> Self {
316        Self {
317            commands: Vec::with_capacity(capacity),
318        }
319    }
320
321    /// Clears all commands.
322    pub fn clear(&mut self) {
323        self.commands.clear();
324    }
325
326    /// Adds a command to the frame.
327    pub fn push(&mut self, cmd: Canvas2DCommand) {
328        self.commands.push(cmd);
329    }
330
331    /// Clears the canvas with a color.
332    pub fn clear_screen(&mut self, color: Color) {
333        self.push(Canvas2DCommand::Clear { color });
334    }
335
336    /// Draws a filled rectangle.
337    pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) {
338        self.push(Canvas2DCommand::FillRect {
339            x,
340            y,
341            width,
342            height,
343            color,
344        });
345    }
346
347    /// Draws a filled circle.
348    pub fn fill_circle(&mut self, x: f32, y: f32, radius: f32, color: Color) {
349        self.push(Canvas2DCommand::FillCircle {
350            x,
351            y,
352            radius,
353            color,
354        });
355    }
356
357    /// Draws text.
358    pub fn fill_text(&mut self, text: &str, x: f32, y: f32, font: &str, color: Color) {
359        self.push(Canvas2DCommand::FillText {
360            text: text.to_string(),
361            x,
362            y,
363            font: font.to_string(),
364            color,
365            align: TextAlign::default(),
366            baseline: TextBaseline::default(),
367        });
368    }
369
370    /// Draws text with alignment options.
371    #[allow(clippy::too_many_arguments)]
372    pub fn fill_text_aligned(
373        &mut self,
374        text: &str,
375        x: f32,
376        y: f32,
377        font: &str,
378        color: Color,
379        align: TextAlign,
380        baseline: TextBaseline,
381    ) {
382        self.push(Canvas2DCommand::FillText {
383            text: text.to_string(),
384            x,
385            y,
386            font: font.to_string(),
387            color,
388            align,
389            baseline,
390        });
391    }
392
393    /// Draws a line.
394    pub fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, line_width: f32) {
395        self.push(Canvas2DCommand::Line {
396            x1,
397            y1,
398            x2,
399            y2,
400            color,
401            line_width,
402        });
403    }
404
405    /// Strokes a rectangle outline.
406    pub fn stroke_rect(
407        &mut self,
408        x: f32,
409        y: f32,
410        width: f32,
411        height: f32,
412        color: Color,
413        line_width: f32,
414    ) {
415        self.push(Canvas2DCommand::StrokeRect {
416            x,
417            y,
418            width,
419            height,
420            color,
421            line_width,
422        });
423    }
424
425    /// Returns the number of commands.
426    #[must_use]
427    #[allow(clippy::missing_const_for_fn)] // Vec::len() is not const
428    pub fn len(&self) -> usize {
429        self.commands.len()
430    }
431
432    /// Checks if the frame has no commands.
433    #[must_use]
434    #[allow(clippy::missing_const_for_fn)] // Vec::is_empty() is not const
435    pub fn is_empty(&self) -> bool {
436        self.commands.is_empty()
437    }
438
439    /// Serializes the frame to JSON.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if serialization fails.
444    pub fn to_json(&self) -> Result<String, serde_json::Error> {
445        serde_json::to_string(&self.commands)
446    }
447
448    /// Serializes the frame to pretty-printed JSON.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if serialization fails.
453    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
454        serde_json::to_string_pretty(&self.commands)
455    }
456}
457
458/// Converts jugar-render `RenderCommand` to `Canvas2DCommand`.
459///
460/// This bridges the platform-agnostic render commands to Canvas2D-specific commands.
461#[must_use]
462#[allow(clippy::missing_const_for_fn)]
463pub fn convert_render_command(cmd: &jugar_render::RenderCommand) -> Option<Canvas2DCommand> {
464    match cmd {
465        jugar_render::RenderCommand::Clear { color } => Some(Canvas2DCommand::Clear {
466            color: Color::from_array(*color),
467        }),
468        jugar_render::RenderCommand::DrawRect { rect, color } => Some(Canvas2DCommand::FillRect {
469            x: rect.x,
470            y: rect.y,
471            width: rect.width,
472            height: rect.height,
473            color: Color::from_array(*color),
474        }),
475        jugar_render::RenderCommand::DrawSprite { .. } => {
476            // Sprites require texture management which is handled separately
477            None
478        }
479    }
480}
481
482/// Converts a slice of `RenderCommand`s to a `RenderFrame`.
483#[must_use]
484pub fn convert_render_queue(commands: &[jugar_render::RenderCommand]) -> RenderFrame {
485    let mut frame = RenderFrame::with_capacity(commands.len());
486    for cmd in commands {
487        if let Some(canvas_cmd) = convert_render_command(cmd) {
488            frame.push(canvas_cmd);
489        }
490    }
491    frame
492}
493
494#[cfg(test)]
495#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_color_new() {
501        let color = Color::new(0.5, 0.25, 0.75, 1.0);
502        assert!((color.r - 0.5).abs() < f32::EPSILON);
503        assert!((color.g - 0.25).abs() < f32::EPSILON);
504        assert!((color.b - 0.75).abs() < f32::EPSILON);
505        assert!((color.a - 1.0).abs() < f32::EPSILON);
506    }
507
508    #[test]
509    fn test_color_from_array() {
510        let color = Color::from_array([0.1, 0.2, 0.3, 0.4]);
511        assert!((color.r - 0.1).abs() < f32::EPSILON);
512        assert!((color.g - 0.2).abs() < f32::EPSILON);
513        assert!((color.b - 0.3).abs() < f32::EPSILON);
514        assert!((color.a - 0.4).abs() < f32::EPSILON);
515    }
516
517    #[test]
518    fn test_color_to_array() {
519        let color = Color::new(0.1, 0.2, 0.3, 0.4);
520        let arr = color.to_array();
521        assert!((arr[0] - 0.1).abs() < f32::EPSILON);
522        assert!((arr[1] - 0.2).abs() < f32::EPSILON);
523        assert!((arr[2] - 0.3).abs() < f32::EPSILON);
524        assert!((arr[3] - 0.4).abs() < f32::EPSILON);
525    }
526
527    #[test]
528    fn test_color_to_css_rgba() {
529        let color = Color::new(1.0, 0.5, 0.0, 0.8);
530        let css = color.to_css_rgba();
531        assert_eq!(css, "rgba(255, 127, 0, 0.8)");
532    }
533
534    #[test]
535    fn test_color_constants() {
536        assert_eq!(Color::BLACK, Color::new(0.0, 0.0, 0.0, 1.0));
537        assert_eq!(Color::WHITE, Color::new(1.0, 1.0, 1.0, 1.0));
538        assert_eq!(Color::RED, Color::new(1.0, 0.0, 0.0, 1.0));
539        assert_eq!(Color::GREEN, Color::new(0.0, 1.0, 0.0, 1.0));
540        assert_eq!(Color::BLUE, Color::new(0.0, 0.0, 1.0, 1.0));
541        assert_eq!(Color::TRANSPARENT, Color::new(0.0, 0.0, 0.0, 0.0));
542    }
543
544    #[test]
545    fn test_color_default() {
546        let color = Color::default();
547        assert_eq!(color, Color::BLACK);
548    }
549
550    #[test]
551    fn test_color_from_trait() {
552        let color: Color = [0.5, 0.5, 0.5, 1.0].into();
553        assert!((color.r - 0.5).abs() < f32::EPSILON);
554    }
555
556    #[test]
557    fn test_color_into_array() {
558        let color = Color::new(0.5, 0.5, 0.5, 1.0);
559        let arr: [f32; 4] = color.into();
560        assert!((arr[0] - 0.5).abs() < f32::EPSILON);
561    }
562
563    #[test]
564    fn test_text_align_default() {
565        assert_eq!(TextAlign::default(), TextAlign::Left);
566    }
567
568    #[test]
569    fn test_text_baseline_default() {
570        assert_eq!(TextBaseline::default(), TextBaseline::Middle);
571    }
572
573    #[test]
574    fn test_canvas2d_command_clear_serialization() {
575        let cmd = Canvas2DCommand::Clear {
576            color: Color::BLACK,
577        };
578        let json = serde_json::to_string(&cmd).unwrap();
579        assert!(json.contains("\"type\":\"Clear\""));
580
581        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
582        assert_eq!(cmd, deserialized);
583    }
584
585    #[test]
586    fn test_canvas2d_command_fill_rect_serialization() {
587        let cmd = Canvas2DCommand::FillRect {
588            x: 10.0,
589            y: 20.0,
590            width: 100.0,
591            height: 50.0,
592            color: Color::WHITE,
593        };
594        let json = serde_json::to_string(&cmd).unwrap();
595        assert!(json.contains("\"type\":\"FillRect\""));
596        assert!(json.contains("\"x\":10"));
597        assert!(json.contains("\"width\":100"));
598
599        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
600        assert_eq!(cmd, deserialized);
601    }
602
603    #[test]
604    fn test_canvas2d_command_fill_circle_serialization() {
605        let cmd = Canvas2DCommand::FillCircle {
606            x: 50.0,
607            y: 50.0,
608            radius: 25.0,
609            color: Color::RED,
610        };
611        let json = serde_json::to_string(&cmd).unwrap();
612        assert!(json.contains("\"type\":\"FillCircle\""));
613        assert!(json.contains("\"radius\":25"));
614
615        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
616        assert_eq!(cmd, deserialized);
617    }
618
619    #[test]
620    fn test_canvas2d_command_stroke_rect_serialization() {
621        let cmd = Canvas2DCommand::StrokeRect {
622            x: 0.0,
623            y: 0.0,
624            width: 200.0,
625            height: 150.0,
626            color: Color::GREEN,
627            line_width: 2.0,
628        };
629        let json = serde_json::to_string(&cmd).unwrap();
630        assert!(json.contains("\"type\":\"StrokeRect\""));
631        assert!(json.contains("\"line_width\":2"));
632
633        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
634        assert_eq!(cmd, deserialized);
635    }
636
637    #[test]
638    fn test_canvas2d_command_stroke_circle_serialization() {
639        let cmd = Canvas2DCommand::StrokeCircle {
640            x: 100.0,
641            y: 100.0,
642            radius: 50.0,
643            color: Color::BLUE,
644            line_width: 3.0,
645        };
646        let json = serde_json::to_string(&cmd).unwrap();
647        assert!(json.contains("\"type\":\"StrokeCircle\""));
648
649        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
650        assert_eq!(cmd, deserialized);
651    }
652
653    #[test]
654    fn test_canvas2d_command_line_serialization() {
655        let cmd = Canvas2DCommand::Line {
656            x1: 0.0,
657            y1: 0.0,
658            x2: 100.0,
659            y2: 100.0,
660            color: Color::WHITE,
661            line_width: 1.0,
662        };
663        let json = serde_json::to_string(&cmd).unwrap();
664        assert!(json.contains("\"type\":\"Line\""));
665        assert!(json.contains("\"x1\":0"));
666        assert!(json.contains("\"x2\":100"));
667
668        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
669        assert_eq!(cmd, deserialized);
670    }
671
672    #[test]
673    fn test_canvas2d_command_fill_text_serialization() {
674        let cmd = Canvas2DCommand::FillText {
675            text: "Score: 42".to_string(),
676            x: 10.0,
677            y: 30.0,
678            font: "24px monospace".to_string(),
679            color: Color::WHITE,
680            align: TextAlign::Left,
681            baseline: TextBaseline::Top,
682        };
683        let json = serde_json::to_string(&cmd).unwrap();
684        assert!(json.contains("\"type\":\"FillText\""));
685        assert!(json.contains("\"text\":\"Score: 42\""));
686        assert!(json.contains("\"font\":\"24px monospace\""));
687        assert!(json.contains("\"align\":\"left\""));
688        assert!(json.contains("\"baseline\":\"top\""));
689
690        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
691        assert_eq!(cmd, deserialized);
692    }
693
694    #[test]
695    fn test_canvas2d_command_draw_image_serialization() {
696        let cmd = Canvas2DCommand::DrawImage {
697            texture_id: 0,
698            x: 100.0,
699            y: 100.0,
700            width: 64.0,
701            height: 64.0,
702        };
703        let json = serde_json::to_string(&cmd).unwrap();
704        assert!(json.contains("\"type\":\"DrawImage\""));
705        assert!(json.contains("\"texture_id\":0"));
706
707        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
708        assert_eq!(cmd, deserialized);
709    }
710
711    #[test]
712    fn test_canvas2d_command_draw_image_slice_serialization() {
713        let cmd = Canvas2DCommand::DrawImageSlice {
714            texture_id: 1,
715            src_x: 0.0,
716            src_y: 0.0,
717            src_width: 32.0,
718            src_height: 32.0,
719            dst_x: 200.0,
720            dst_y: 200.0,
721            dst_width: 64.0,
722            dst_height: 64.0,
723        };
724        let json = serde_json::to_string(&cmd).unwrap();
725        assert!(json.contains("\"type\":\"DrawImageSlice\""));
726        assert!(json.contains("\"src_width\":32"));
727        assert!(json.contains("\"dst_width\":64"));
728
729        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
730        assert_eq!(cmd, deserialized);
731    }
732
733    #[test]
734    fn test_canvas2d_command_transform_serialization() {
735        // Save
736        let save = Canvas2DCommand::Save;
737        let json = serde_json::to_string(&save).unwrap();
738        assert!(json.contains("\"type\":\"Save\""));
739
740        // Restore
741        let restore = Canvas2DCommand::Restore;
742        let json = serde_json::to_string(&restore).unwrap();
743        assert!(json.contains("\"type\":\"Restore\""));
744
745        // Translate
746        let translate = Canvas2DCommand::Translate { x: 50.0, y: 100.0 };
747        let json = serde_json::to_string(&translate).unwrap();
748        assert!(json.contains("\"type\":\"Translate\""));
749
750        // Rotate
751        let rotate = Canvas2DCommand::Rotate {
752            angle: core::f32::consts::PI,
753        };
754        let json = serde_json::to_string(&rotate).unwrap();
755        assert!(json.contains("\"type\":\"Rotate\""));
756
757        // Scale
758        let scale = Canvas2DCommand::Scale { x: 2.0, y: 2.0 };
759        let json = serde_json::to_string(&scale).unwrap();
760        assert!(json.contains("\"type\":\"Scale\""));
761    }
762
763    #[test]
764    fn test_canvas2d_command_set_alpha_serialization() {
765        let cmd = Canvas2DCommand::SetAlpha { alpha: 0.5 };
766        let json = serde_json::to_string(&cmd).unwrap();
767        assert!(json.contains("\"type\":\"SetAlpha\""));
768        assert!(json.contains("\"alpha\":0.5"));
769
770        let deserialized: Canvas2DCommand = serde_json::from_str(&json).unwrap();
771        assert_eq!(cmd, deserialized);
772    }
773
774    #[test]
775    fn test_render_frame_new() {
776        let frame = RenderFrame::new();
777        assert!(frame.is_empty());
778        assert_eq!(frame.len(), 0);
779    }
780
781    #[test]
782    fn test_render_frame_with_capacity() {
783        let frame = RenderFrame::with_capacity(100);
784        assert!(frame.is_empty());
785        assert!(frame.commands.capacity() >= 100);
786    }
787
788    #[test]
789    fn test_render_frame_push() {
790        let mut frame = RenderFrame::new();
791        frame.push(Canvas2DCommand::Clear {
792            color: Color::BLACK,
793        });
794        assert_eq!(frame.len(), 1);
795        assert!(!frame.is_empty());
796    }
797
798    #[test]
799    fn test_render_frame_clear() {
800        let mut frame = RenderFrame::new();
801        frame.push(Canvas2DCommand::Clear {
802            color: Color::BLACK,
803        });
804        frame.clear();
805        assert!(frame.is_empty());
806    }
807
808    #[test]
809    fn test_render_frame_clear_screen() {
810        let mut frame = RenderFrame::new();
811        frame.clear_screen(Color::BLACK);
812        assert_eq!(frame.len(), 1);
813        assert_eq!(
814            frame.commands[0],
815            Canvas2DCommand::Clear {
816                color: Color::BLACK
817            }
818        );
819    }
820
821    #[test]
822    fn test_render_frame_fill_rect() {
823        let mut frame = RenderFrame::new();
824        frame.fill_rect(10.0, 20.0, 100.0, 50.0, Color::WHITE);
825        assert_eq!(frame.len(), 1);
826        match &frame.commands[0] {
827            Canvas2DCommand::FillRect {
828                x,
829                y,
830                width,
831                height,
832                ..
833            } => {
834                assert!((x - 10.0).abs() < f32::EPSILON);
835                assert!((y - 20.0).abs() < f32::EPSILON);
836                assert!((width - 100.0).abs() < f32::EPSILON);
837                assert!((height - 50.0).abs() < f32::EPSILON);
838            }
839            _ => panic!("Expected FillRect"),
840        }
841    }
842
843    #[test]
844    fn test_render_frame_fill_circle() {
845        let mut frame = RenderFrame::new();
846        frame.fill_circle(50.0, 50.0, 25.0, Color::RED);
847        assert_eq!(frame.len(), 1);
848        match &frame.commands[0] {
849            Canvas2DCommand::FillCircle { x, y, radius, .. } => {
850                assert!((x - 50.0).abs() < f32::EPSILON);
851                assert!((y - 50.0).abs() < f32::EPSILON);
852                assert!((radius - 25.0).abs() < f32::EPSILON);
853            }
854            _ => panic!("Expected FillCircle"),
855        }
856    }
857
858    #[test]
859    fn test_render_frame_fill_text() {
860        let mut frame = RenderFrame::new();
861        frame.fill_text("Hello", 10.0, 20.0, "24px sans-serif", Color::WHITE);
862        assert_eq!(frame.len(), 1);
863        match &frame.commands[0] {
864            Canvas2DCommand::FillText { text, font, .. } => {
865                assert_eq!(text, "Hello");
866                assert_eq!(font, "24px sans-serif");
867            }
868            _ => panic!("Expected FillText"),
869        }
870    }
871
872    #[test]
873    fn test_render_frame_fill_text_aligned() {
874        let mut frame = RenderFrame::new();
875        frame.fill_text_aligned(
876            "Centered",
877            100.0,
878            50.0,
879            "32px monospace",
880            Color::WHITE,
881            TextAlign::Center,
882            TextBaseline::Middle,
883        );
884        assert_eq!(frame.len(), 1);
885        match &frame.commands[0] {
886            Canvas2DCommand::FillText {
887                align, baseline, ..
888            } => {
889                assert_eq!(*align, TextAlign::Center);
890                assert_eq!(*baseline, TextBaseline::Middle);
891            }
892            _ => panic!("Expected FillText"),
893        }
894    }
895
896    #[test]
897    fn test_render_frame_line() {
898        let mut frame = RenderFrame::new();
899        frame.line(0.0, 0.0, 100.0, 100.0, Color::WHITE, 2.0);
900        assert_eq!(frame.len(), 1);
901        match &frame.commands[0] {
902            Canvas2DCommand::Line {
903                x1,
904                y1,
905                x2,
906                y2,
907                line_width,
908                ..
909            } => {
910                assert!((x1 - 0.0).abs() < f32::EPSILON);
911                assert!((y1 - 0.0).abs() < f32::EPSILON);
912                assert!((x2 - 100.0).abs() < f32::EPSILON);
913                assert!((y2 - 100.0).abs() < f32::EPSILON);
914                assert!((line_width - 2.0).abs() < f32::EPSILON);
915            }
916            _ => panic!("Expected Line"),
917        }
918    }
919
920    #[test]
921    fn test_render_frame_to_json() {
922        let mut frame = RenderFrame::new();
923        frame.clear_screen(Color::BLACK);
924        frame.fill_rect(50.0, 200.0, 20.0, 120.0, Color::WHITE);
925
926        let json = frame.to_json().unwrap();
927        assert!(json.contains("\"type\":\"Clear\""));
928        assert!(json.contains("\"type\":\"FillRect\""));
929    }
930
931    #[test]
932    fn test_render_frame_to_json_pretty() {
933        let mut frame = RenderFrame::new();
934        frame.clear_screen(Color::BLACK);
935
936        let json = frame.to_json_pretty().unwrap();
937        assert!(json.contains('\n')); // Pretty print has newlines
938    }
939
940    #[test]
941    fn test_render_frame_default() {
942        let frame = RenderFrame::default();
943        assert!(frame.is_empty());
944    }
945
946    #[test]
947    fn test_pong_like_frame() {
948        // Simulate a Pong game frame
949        let mut frame = RenderFrame::new();
950
951        // Clear screen black
952        frame.clear_screen(Color::BLACK);
953
954        // Draw center line
955        frame.line(400.0, 0.0, 400.0, 600.0, Color::WHITE, 2.0);
956
957        // Left paddle
958        frame.fill_rect(20.0, 250.0, 10.0, 100.0, Color::WHITE);
959
960        // Right paddle
961        frame.fill_rect(770.0, 250.0, 10.0, 100.0, Color::WHITE);
962
963        // Ball
964        frame.fill_circle(400.0, 300.0, 10.0, Color::WHITE);
965
966        // Scores
967        frame.fill_text_aligned(
968            "3",
969            200.0,
970            50.0,
971            "48px monospace",
972            Color::WHITE,
973            TextAlign::Center,
974            TextBaseline::Top,
975        );
976        frame.fill_text_aligned(
977            "5",
978            600.0,
979            50.0,
980            "48px monospace",
981            Color::WHITE,
982            TextAlign::Center,
983            TextBaseline::Top,
984        );
985
986        assert_eq!(frame.len(), 7);
987
988        let json = frame.to_json().unwrap();
989        // Verify JSON can be parsed
990        let commands: Vec<Canvas2DCommand> = serde_json::from_str(&json).unwrap();
991        assert_eq!(commands.len(), 7);
992    }
993
994    #[test]
995    fn test_convert_render_command_clear() {
996        let cmd = jugar_render::RenderCommand::Clear {
997            color: [0.0, 0.0, 0.0, 1.0],
998        };
999        let converted = convert_render_command(&cmd).unwrap();
1000        assert!(matches!(converted, Canvas2DCommand::Clear { .. }));
1001    }
1002
1003    #[test]
1004    fn test_convert_render_command_draw_rect() {
1005        let cmd = jugar_render::RenderCommand::DrawRect {
1006            rect: jugar_core::Rect::new(10.0, 20.0, 100.0, 50.0),
1007            color: [1.0, 1.0, 1.0, 1.0],
1008        };
1009        let converted = convert_render_command(&cmd).unwrap();
1010        match converted {
1011            Canvas2DCommand::FillRect {
1012                x,
1013                y,
1014                width,
1015                height,
1016                ..
1017            } => {
1018                assert!((x - 10.0).abs() < f32::EPSILON);
1019                assert!((y - 20.0).abs() < f32::EPSILON);
1020                assert!((width - 100.0).abs() < f32::EPSILON);
1021                assert!((height - 50.0).abs() < f32::EPSILON);
1022            }
1023            _ => panic!("Expected FillRect"),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_convert_render_command_sprite_returns_none() {
1029        use glam::Vec2;
1030        use jugar_core::Position;
1031        let cmd = jugar_render::RenderCommand::DrawSprite {
1032            texture_id: 0,
1033            position: Position::zero(),
1034            size: Vec2::new(64.0, 64.0),
1035            source: None,
1036            color: [1.0, 1.0, 1.0, 1.0],
1037        };
1038        assert!(convert_render_command(&cmd).is_none());
1039    }
1040
1041    #[test]
1042    fn test_convert_render_queue() {
1043        let commands = vec![
1044            jugar_render::RenderCommand::Clear {
1045                color: [0.0, 0.0, 0.0, 1.0],
1046            },
1047            jugar_render::RenderCommand::DrawRect {
1048                rect: jugar_core::Rect::new(0.0, 0.0, 100.0, 100.0),
1049                color: [1.0, 1.0, 1.0, 1.0],
1050            },
1051        ];
1052
1053        let frame = convert_render_queue(&commands);
1054        assert_eq!(frame.len(), 2);
1055    }
1056
1057    #[test]
1058    fn test_convert_render_queue_skips_sprites() {
1059        use glam::Vec2;
1060        use jugar_core::Position;
1061        let commands = vec![
1062            jugar_render::RenderCommand::Clear {
1063                color: [0.0, 0.0, 0.0, 1.0],
1064            },
1065            jugar_render::RenderCommand::DrawSprite {
1066                texture_id: 0,
1067                position: Position::zero(),
1068                size: Vec2::new(64.0, 64.0),
1069                source: None,
1070                color: [1.0, 1.0, 1.0, 1.0],
1071            },
1072            jugar_render::RenderCommand::DrawRect {
1073                rect: jugar_core::Rect::new(0.0, 0.0, 100.0, 100.0),
1074                color: [1.0, 1.0, 1.0, 1.0],
1075            },
1076        ];
1077
1078        let frame = convert_render_queue(&commands);
1079        assert_eq!(frame.len(), 2); // Sprite is skipped
1080    }
1081
1082    #[test]
1083    fn test_text_align_serialization() {
1084        assert_eq!(serde_json::to_string(&TextAlign::Left).unwrap(), "\"left\"");
1085        assert_eq!(
1086            serde_json::to_string(&TextAlign::Center).unwrap(),
1087            "\"center\""
1088        );
1089        assert_eq!(
1090            serde_json::to_string(&TextAlign::Right).unwrap(),
1091            "\"right\""
1092        );
1093    }
1094
1095    #[test]
1096    fn test_text_baseline_serialization() {
1097        assert_eq!(
1098            serde_json::to_string(&TextBaseline::Top).unwrap(),
1099            "\"top\""
1100        );
1101        assert_eq!(
1102            serde_json::to_string(&TextBaseline::Middle).unwrap(),
1103            "\"middle\""
1104        );
1105        assert_eq!(
1106            serde_json::to_string(&TextBaseline::Bottom).unwrap(),
1107            "\"bottom\""
1108        );
1109        assert_eq!(
1110            serde_json::to_string(&TextBaseline::Alphabetic).unwrap(),
1111            "\"alphabetic\""
1112        );
1113    }
1114}