1use crate::result::{ProbarError, ProbarResult};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GeneratedHtml {
11 pub title: String,
13 pub body_content: String,
15 pub content: String,
17 pub elements: Vec<Element>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub enum Element {
24 Canvas {
26 id: String,
28 width: u32,
30 height: u32,
32 role: String,
34 aria_label: String,
36 },
37 Div {
39 id: String,
41 classes: Vec<String>,
43 content: String,
45 },
46 Button {
48 id: String,
50 text: String,
52 aria_label: String,
54 },
55 Input {
57 id: String,
59 input_type: String,
61 placeholder: String,
63 aria_label: String,
65 },
66}
67
68impl Element {
69 #[must_use]
71 pub fn render(&self) -> String {
72 match self {
73 Element::Canvas {
74 id,
75 width,
76 height,
77 role,
78 aria_label,
79 } => {
80 format!(
81 r#"<canvas id="{id}" width="{width}" height="{height}" role="{role}" aria-label="{aria_label}" tabindex="0"></canvas>"#
82 )
83 }
84 Element::Div {
85 id,
86 classes,
87 content,
88 } => {
89 let class_attr = if classes.is_empty() {
90 String::new()
91 } else {
92 format!(r#" class="{}""#, classes.join(" "))
93 };
94 format!(r#"<div id="{id}"{class_attr}>{content}</div>"#)
95 }
96 Element::Button {
97 id,
98 text,
99 aria_label,
100 } => {
101 format!(r#"<button id="{id}" aria-label="{aria_label}">{text}</button>"#)
102 }
103 Element::Input {
104 id,
105 input_type,
106 placeholder,
107 aria_label,
108 } => {
109 format!(
110 r#"<input id="{id}" type="{input_type}" placeholder="{placeholder}" aria-label="{aria_label}">"#
111 )
112 }
113 }
114 }
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct HtmlDocument {
120 pub title: String,
122 pub lang: String,
124 pub charset: String,
126 pub viewport: String,
128 pub elements: Vec<Element>,
130}
131
132#[derive(Debug, Clone, Default)]
134pub struct HtmlBuilder {
135 document: HtmlDocument,
136}
137
138impl HtmlBuilder {
139 #[must_use]
141 pub fn new() -> Self {
142 Self {
143 document: HtmlDocument {
144 title: String::new(),
145 lang: "en".to_string(),
146 charset: "UTF-8".to_string(),
147 viewport: "width=device-width, initial-scale=1.0".to_string(),
148 elements: Vec::new(),
149 },
150 }
151 }
152
153 #[must_use]
155 pub fn title(mut self, title: &str) -> Self {
156 self.document.title = title.to_string();
157 self
158 }
159
160 #[must_use]
162 pub fn lang(mut self, lang: &str) -> Self {
163 self.document.lang = lang.to_string();
164 self
165 }
166
167 #[must_use]
169 pub fn canvas(mut self, id: &str, width: u32, height: u32) -> Self {
170 self.document.elements.push(Element::Canvas {
171 id: id.to_string(),
172 width,
173 height,
174 role: "application".to_string(),
175 aria_label: "Application canvas".to_string(),
176 });
177 self
178 }
179
180 #[must_use]
182 pub fn canvas_with_a11y(
183 mut self,
184 id: &str,
185 width: u32,
186 height: u32,
187 role: &str,
188 aria_label: &str,
189 ) -> Self {
190 self.document.elements.push(Element::Canvas {
191 id: id.to_string(),
192 width,
193 height,
194 role: role.to_string(),
195 aria_label: aria_label.to_string(),
196 });
197 self
198 }
199
200 #[must_use]
202 pub fn div(mut self, id: &str, classes: &[&str], content: &str) -> Self {
203 self.document.elements.push(Element::Div {
204 id: id.to_string(),
205 classes: classes.iter().map(|s| (*s).to_string()).collect(),
206 content: content.to_string(),
207 });
208 self
209 }
210
211 #[must_use]
213 pub fn button(mut self, id: &str, text: &str, aria_label: &str) -> Self {
214 self.document.elements.push(Element::Button {
215 id: id.to_string(),
216 text: text.to_string(),
217 aria_label: aria_label.to_string(),
218 });
219 self
220 }
221
222 #[must_use]
224 pub fn input(
225 mut self,
226 id: &str,
227 input_type: &str,
228 placeholder: &str,
229 aria_label: &str,
230 ) -> Self {
231 self.document.elements.push(Element::Input {
232 id: id.to_string(),
233 input_type: input_type.to_string(),
234 placeholder: placeholder.to_string(),
235 aria_label: aria_label.to_string(),
236 });
237 self
238 }
239
240 #[must_use]
242 pub fn element(mut self, element: Element) -> Self {
243 self.document.elements.push(element);
244 self
245 }
246
247 pub fn build(self) -> ProbarResult<GeneratedHtml> {
253 if self.document.title.is_empty() {
255 return Err(ProbarError::HtmlGeneration(
256 "Document title is required".to_string(),
257 ));
258 }
259
260 let body_content = self
262 .document
263 .elements
264 .iter()
265 .map(Element::render)
266 .collect::<Vec<_>>()
267 .join("\n ");
268
269 let content = format!(
271 r#"<!DOCTYPE html>
272<html lang="{lang}">
273<head>
274 <meta charset="{charset}">
275 <meta name="viewport" content="{viewport}">
276 <title>{title}</title>
277</head>
278<body>
279 {body}
280</body>
281</html>"#,
282 lang = self.document.lang,
283 charset = self.document.charset,
284 viewport = self.document.viewport,
285 title = self.document.title,
286 body = body_content,
287 );
288
289 Ok(GeneratedHtml {
290 title: self.document.title,
291 body_content,
292 content,
293 elements: self.document.elements,
294 })
295 }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301 use super::*;
302
303 #[test]
308 fn h0_html_01_builder_new() {
309 let builder = HtmlBuilder::new();
310 assert_eq!(builder.document.lang, "en");
311 assert_eq!(builder.document.charset, "UTF-8");
312 }
313
314 #[test]
315 fn h0_html_02_builder_title() {
316 let builder = HtmlBuilder::new().title("My App");
317 assert_eq!(builder.document.title, "My App");
318 }
319
320 #[test]
321 fn h0_html_03_builder_lang() {
322 let builder = HtmlBuilder::new().lang("es");
323 assert_eq!(builder.document.lang, "es");
324 }
325
326 #[test]
331 fn h0_html_04_canvas_element() {
332 let html = HtmlBuilder::new()
333 .title("Test")
334 .canvas("game", 800, 600)
335 .build()
336 .unwrap();
337
338 assert!(html.content.contains(r#"id="game""#));
339 assert!(html.content.contains(r#"width="800""#));
340 assert!(html.content.contains(r#"height="600""#));
341 assert!(html.content.contains(r#"role="application""#));
342 assert!(html.content.contains(r#"aria-label="Application canvas""#));
343 assert!(html.content.contains(r#"tabindex="0""#));
344 }
345
346 #[test]
347 fn h0_html_05_canvas_custom_a11y() {
348 let html = HtmlBuilder::new()
349 .title("Test")
350 .canvas_with_a11y("calc", 400, 300, "img", "Calculator display")
351 .build()
352 .unwrap();
353
354 assert!(html.content.contains(r#"role="img""#));
355 assert!(html.content.contains(r#"aria-label="Calculator display""#));
356 }
357
358 #[test]
363 fn h0_html_06_div_element() {
364 let html = HtmlBuilder::new()
365 .title("Test")
366 .div("container", &["main", "flex"], "Hello")
367 .build()
368 .unwrap();
369
370 assert!(html
371 .content
372 .contains(r#"<div id="container" class="main flex">Hello</div>"#));
373 }
374
375 #[test]
376 fn h0_html_07_button_element() {
377 let html = HtmlBuilder::new()
378 .title("Test")
379 .button("submit", "Submit", "Submit form")
380 .build()
381 .unwrap();
382
383 assert!(html
384 .content
385 .contains(r#"<button id="submit" aria-label="Submit form">Submit</button>"#));
386 }
387
388 #[test]
389 fn h0_html_08_input_element() {
390 let html = HtmlBuilder::new()
391 .title("Test")
392 .input("email", "email", "Enter email", "Email address")
393 .build()
394 .unwrap();
395
396 assert!(html.content.contains(r#"<input id="email""#));
397 assert!(html.content.contains(r#"type="email""#));
398 assert!(html.content.contains(r#"placeholder="Enter email""#));
399 }
400
401 #[test]
406 fn h0_html_09_empty_title_fails() {
407 let result = HtmlBuilder::new().build();
408 assert!(result.is_err());
409 assert!(result
410 .unwrap_err()
411 .to_string()
412 .contains("title is required"));
413 }
414
415 #[test]
416 fn h0_html_10_valid_html_structure() {
417 let html = HtmlBuilder::new()
418 .title("Test App")
419 .canvas("app", 100, 100)
420 .build()
421 .unwrap();
422
423 assert!(html.content.starts_with("<!DOCTYPE html>"));
424 assert!(html.content.contains("<html lang=\"en\">"));
425 assert!(html.content.contains("<head>"));
426 assert!(html.content.contains("</head>"));
427 assert!(html.content.contains("<body>"));
428 assert!(html.content.contains("</body>"));
429 assert!(html.content.contains("</html>"));
430 }
431
432 #[test]
437 fn h0_html_11_element_render_canvas() {
438 let elem = Element::Canvas {
439 id: "c".to_string(),
440 width: 100,
441 height: 100,
442 role: "img".to_string(),
443 aria_label: "Test".to_string(),
444 };
445
446 let rendered = elem.render();
447 assert!(rendered.contains("<canvas"));
448 assert!(rendered.contains("</canvas>"));
449 }
450
451 #[test]
452 fn h0_html_12_element_render_div_no_classes() {
453 let elem = Element::Div {
454 id: "d".to_string(),
455 classes: vec![],
456 content: "Test".to_string(),
457 };
458
459 let rendered = elem.render();
460 assert_eq!(rendered, r#"<div id="d">Test</div>"#);
461 }
462
463 #[test]
464 fn h0_html_13_generated_html_fields() {
465 let html = HtmlBuilder::new()
466 .title("My Title")
467 .canvas("c", 10, 10)
468 .build()
469 .unwrap();
470
471 assert_eq!(html.title, "My Title");
472 assert!(!html.body_content.is_empty());
473 assert!(!html.elements.is_empty());
474 }
475}