1mod css_builder;
11mod html_builder;
12mod js_builder;
13mod validator;
14
15pub use css_builder::{CssBuilder, CssRule, GeneratedCss};
16pub use html_builder::{Element, GeneratedHtml, HtmlBuilder, HtmlDocument};
17pub use js_builder::{GeneratedJs, JsBuilder, WasmConfig};
18pub use validator::{
19 AccessibilityIssue as WebAccessibilityIssue, CssLintResult, HtmlValidationResult, JsLintResult,
20 SecurityIssue, Severity as WebSeverity, ValidationReport, WebValidator,
21};
22
23#[derive(Debug, Clone)]
25pub struct WebBundle {
26 pub html: GeneratedHtml,
28 pub css: GeneratedCss,
30 pub js: GeneratedJs,
32 pub validation: ValidationReport,
34}
35
36impl WebBundle {
37 #[must_use]
39 pub fn new(html: GeneratedHtml, css: GeneratedCss, js: GeneratedJs) -> Self {
40 let validation = WebValidator::validate_all(&html, &css, &js);
41 Self {
42 html,
43 css,
44 js,
45 validation,
46 }
47 }
48
49 #[must_use]
51 pub fn is_valid(&self) -> bool {
52 self.validation.is_valid()
53 }
54
55 #[must_use]
57 pub fn to_single_file(&self) -> String {
58 format!(
59 r#"<!DOCTYPE html>
60<html lang="en">
61<head>
62 <meta charset="UTF-8">
63 <meta name="viewport" content="width=device-width, initial-scale=1.0">
64 <title>{title}</title>
65 <style>
66{css}
67 </style>
68</head>
69<body>
70{body}
71 <script>
72{js}
73 </script>
74</body>
75</html>"#,
76 title = self.html.title,
77 css = self.css.content,
78 body = self.html.body_content,
79 js = self.js.content,
80 )
81 }
82}
83
84#[derive(Debug, Clone, Default)]
86pub struct WebAssetCoverage {
87 pub html_elements: std::collections::HashMap<String, WebElementCoverage>,
89 pub css_rules: std::collections::HashMap<String, RuleCoverage>,
91 pub js_functions: std::collections::HashMap<String, FunctionCoverage>,
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct WebElementCoverage {
98 pub id: String,
100 pub interaction_count: u64,
102 pub interaction_types: Vec<String>,
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct RuleCoverage {
109 pub selector: String,
111 pub application_count: u64,
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct FunctionCoverage {
118 pub name: String,
120 pub execution_count: u64,
122}
123
124impl WebAssetCoverage {
125 #[must_use]
127 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn element_used(&mut self, id: &str, interaction_type: &str) {
133 let entry = self.html_elements.entry(id.to_string()).or_default();
134 entry.id = id.to_string();
135 entry.interaction_count += 1;
136 if !entry
137 .interaction_types
138 .contains(&interaction_type.to_string())
139 {
140 entry.interaction_types.push(interaction_type.to_string());
141 }
142 }
143
144 pub fn rule_applied(&mut self, selector: &str) {
146 let entry = self.css_rules.entry(selector.to_string()).or_default();
147 entry.selector = selector.to_string();
148 entry.application_count += 1;
149 }
150
151 pub fn function_executed(&mut self, name: &str) {
153 let entry = self.js_functions.entry(name.to_string()).or_default();
154 entry.name = name.to_string();
155 entry.execution_count += 1;
156 }
157
158 #[must_use]
160 pub fn coverage_percent(&self) -> f64 {
161 let html_covered = self
162 .html_elements
163 .values()
164 .filter(|e| e.interaction_count > 0)
165 .count();
166 let css_covered = self
167 .css_rules
168 .values()
169 .filter(|r| r.application_count > 0)
170 .count();
171 let js_covered = self
172 .js_functions
173 .values()
174 .filter(|f| f.execution_count > 0)
175 .count();
176
177 let total = self.html_elements.len() + self.css_rules.len() + self.js_functions.len();
178 let covered = html_covered + css_covered + js_covered;
179
180 if total == 0 {
181 100.0
182 } else {
183 (covered as f64 / total as f64) * 100.0
184 }
185 }
186
187 #[must_use]
189 pub fn report(&self) -> WebAssetCoverageReport {
190 WebAssetCoverageReport {
191 html_coverage: self.calculate_html_coverage(),
192 css_coverage: self.calculate_css_coverage(),
193 js_coverage: self.calculate_js_coverage(),
194 overall_coverage: self.coverage_percent(),
195 }
196 }
197
198 fn calculate_html_coverage(&self) -> f64 {
199 let total = self.html_elements.len();
200 let covered = self
201 .html_elements
202 .values()
203 .filter(|e| e.interaction_count > 0)
204 .count();
205 if total == 0 {
206 100.0
207 } else {
208 (covered as f64 / total as f64) * 100.0
209 }
210 }
211
212 fn calculate_css_coverage(&self) -> f64 {
213 let total = self.css_rules.len();
214 let covered = self
215 .css_rules
216 .values()
217 .filter(|r| r.application_count > 0)
218 .count();
219 if total == 0 {
220 100.0
221 } else {
222 (covered as f64 / total as f64) * 100.0
223 }
224 }
225
226 fn calculate_js_coverage(&self) -> f64 {
227 let total = self.js_functions.len();
228 let covered = self
229 .js_functions
230 .values()
231 .filter(|f| f.execution_count > 0)
232 .count();
233 if total == 0 {
234 100.0
235 } else {
236 (covered as f64 / total as f64) * 100.0
237 }
238 }
239}
240
241#[derive(Debug, Clone)]
243pub struct WebAssetCoverageReport {
244 pub html_coverage: f64,
246 pub css_coverage: f64,
248 pub js_coverage: f64,
250 pub overall_coverage: f64,
252}
253
254#[cfg(test)]
255#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
256mod tests {
257 use super::*;
258
259 #[test]
264 fn h0_web_01_bundle_creation() {
265 let html = HtmlBuilder::new()
266 .title("Test App")
267 .canvas("app", 800, 600)
268 .build()
269 .unwrap();
270
271 let css = CssBuilder::new().responsive_canvas("app").build().unwrap();
272
273 let js = JsBuilder::new("app.wasm", "app").build().unwrap();
274
275 let bundle = WebBundle::new(html, css, js);
276 assert!(bundle.is_valid());
277 }
278
279 #[test]
280 fn h0_web_02_bundle_single_file_output() {
281 let html = HtmlBuilder::new()
282 .title("Test")
283 .canvas("canvas", 100, 100)
284 .build()
285 .unwrap();
286
287 let css = CssBuilder::new().build().unwrap();
288 let js = JsBuilder::new("test.wasm", "canvas").build().unwrap();
289
290 let bundle = WebBundle::new(html, css, js);
291 let output = bundle.to_single_file();
292
293 assert!(output.contains("<!DOCTYPE html>"));
294 assert!(output.contains("<title>Test</title>"));
295 assert!(output.contains("<style>"));
296 assert!(output.contains("<script>"));
297 }
298
299 #[test]
304 fn h0_web_03_coverage_tracking() {
305 let mut coverage = WebAssetCoverage::new();
306
307 coverage.html_elements.insert(
309 "button".to_string(),
310 WebElementCoverage {
311 id: "button".to_string(),
312 interaction_count: 0,
313 interaction_types: vec![],
314 },
315 );
316
317 coverage.css_rules.insert(
318 "#button".to_string(),
319 RuleCoverage {
320 selector: "#button".to_string(),
321 application_count: 0,
322 },
323 );
324
325 assert_eq!(coverage.coverage_percent(), 0.0);
327
328 coverage.element_used("button", "click");
330 assert!(coverage.coverage_percent() > 0.0);
331
332 coverage.rule_applied("#button");
334 assert_eq!(coverage.coverage_percent(), 100.0);
335 }
336
337 #[test]
338 fn h0_web_04_coverage_report() {
339 let mut coverage = WebAssetCoverage::new();
340
341 coverage.html_elements.insert(
342 "a".to_string(),
343 WebElementCoverage {
344 id: "a".to_string(),
345 interaction_count: 1,
346 interaction_types: vec!["click".to_string()],
347 },
348 );
349
350 coverage.html_elements.insert(
351 "b".to_string(),
352 WebElementCoverage {
353 id: "b".to_string(),
354 interaction_count: 0,
355 interaction_types: vec![],
356 },
357 );
358
359 let report = coverage.report();
360 assert_eq!(report.html_coverage, 50.0);
361 assert_eq!(report.css_coverage, 100.0); assert_eq!(report.js_coverage, 100.0); }
364
365 #[test]
366 fn h0_web_05_empty_coverage_is_100_percent() {
367 let coverage = WebAssetCoverage::new();
368 assert_eq!(coverage.coverage_percent(), 100.0);
369 }
370
371 #[test]
376 fn h0_web_06_function_executed_tracking() {
377 let mut coverage = WebAssetCoverage::new();
378
379 coverage.function_executed("init");
381
382 assert!(coverage.js_functions.contains_key("init"));
383 let func = coverage.js_functions.get("init").unwrap();
384 assert_eq!(func.name, "init");
385 assert_eq!(func.execution_count, 1);
386
387 coverage.function_executed("init");
389 let func = coverage.js_functions.get("init").unwrap();
390 assert_eq!(func.execution_count, 2);
391 }
392
393 #[test]
394 fn h0_web_07_function_coverage_calculation() {
395 let mut coverage = WebAssetCoverage::new();
396
397 coverage.js_functions.insert(
399 "fn1".to_string(),
400 FunctionCoverage {
401 name: "fn1".to_string(),
402 execution_count: 5,
403 },
404 );
405 coverage.js_functions.insert(
406 "fn2".to_string(),
407 FunctionCoverage {
408 name: "fn2".to_string(),
409 execution_count: 0,
410 },
411 );
412
413 let report = coverage.report();
414 assert_eq!(report.js_coverage, 50.0);
415 }
416
417 #[test]
422 fn h0_web_08_element_used_duplicate_interaction_type() {
423 let mut coverage = WebAssetCoverage::new();
424
425 coverage.element_used("btn", "click");
427 coverage.element_used("btn", "click"); coverage.element_used("btn", "hover"); let elem = coverage.html_elements.get("btn").unwrap();
431 assert_eq!(elem.interaction_count, 3);
432 assert_eq!(elem.interaction_types.len(), 2);
434 assert!(elem.interaction_types.contains(&"click".to_string()));
435 assert!(elem.interaction_types.contains(&"hover".to_string()));
436 }
437
438 #[test]
443 fn h0_web_09_rule_applied_multiple_times() {
444 let mut coverage = WebAssetCoverage::new();
445
446 coverage.rule_applied(".container");
447 coverage.rule_applied(".container");
448 coverage.rule_applied(".container");
449
450 let rule = coverage.css_rules.get(".container").unwrap();
451 assert_eq!(rule.selector, ".container");
452 assert_eq!(rule.application_count, 3);
453 }
454
455 #[test]
460 fn h0_web_10_css_coverage_partial() {
461 let mut coverage = WebAssetCoverage::new();
462
463 coverage.css_rules.insert(
465 "#r1".to_string(),
466 RuleCoverage {
467 selector: "#r1".to_string(),
468 application_count: 1,
469 },
470 );
471 coverage.css_rules.insert(
472 "#r2".to_string(),
473 RuleCoverage {
474 selector: "#r2".to_string(),
475 application_count: 0,
476 },
477 );
478 coverage.css_rules.insert(
479 "#r3".to_string(),
480 RuleCoverage {
481 selector: "#r3".to_string(),
482 application_count: 0,
483 },
484 );
485 coverage.css_rules.insert(
486 "#r4".to_string(),
487 RuleCoverage {
488 selector: "#r4".to_string(),
489 application_count: 0,
490 },
491 );
492
493 let report = coverage.report();
494 assert_eq!(report.css_coverage, 25.0);
495 }
496
497 #[test]
502 fn h0_web_11_overall_coverage_mixed() {
503 let mut coverage = WebAssetCoverage::new();
504
505 coverage.html_elements.insert(
507 "e1".to_string(),
508 WebElementCoverage {
509 id: "e1".to_string(),
510 interaction_count: 1,
511 interaction_types: vec![],
512 },
513 );
514 coverage.html_elements.insert(
515 "e2".to_string(),
516 WebElementCoverage {
517 id: "e2".to_string(),
518 interaction_count: 0,
519 interaction_types: vec![],
520 },
521 );
522
523 coverage.css_rules.insert(
525 "#c1".to_string(),
526 RuleCoverage {
527 selector: "#c1".to_string(),
528 application_count: 1,
529 },
530 );
531 coverage.css_rules.insert(
532 "#c2".to_string(),
533 RuleCoverage {
534 selector: "#c2".to_string(),
535 application_count: 0,
536 },
537 );
538
539 coverage.js_functions.insert(
541 "f1".to_string(),
542 FunctionCoverage {
543 name: "f1".to_string(),
544 execution_count: 1,
545 },
546 );
547 coverage.js_functions.insert(
548 "f2".to_string(),
549 FunctionCoverage {
550 name: "f2".to_string(),
551 execution_count: 0,
552 },
553 );
554
555 assert_eq!(coverage.coverage_percent(), 50.0);
557
558 let report = coverage.report();
559 assert_eq!(report.html_coverage, 50.0);
560 assert_eq!(report.css_coverage, 50.0);
561 assert_eq!(report.js_coverage, 50.0);
562 assert_eq!(report.overall_coverage, 50.0);
563 }
564
565 #[test]
570 fn h0_web_12_bundle_with_invalid_js() {
571 let html = HtmlBuilder::new()
572 .title("Test")
573 .canvas("c", 100, 100)
574 .build()
575 .unwrap();
576
577 let css = CssBuilder::new().build().unwrap();
578
579 let js = GeneratedJs {
581 content: "eval('bad')".to_string(),
582 line_count: 1,
583 functions: vec![],
584 };
585
586 let bundle = WebBundle::new(html, css, js);
587 assert!(!bundle.is_valid());
588 }
589
590 #[test]
595 fn h0_web_13_single_file_contains_all_parts() {
596 let html = HtmlBuilder::new()
597 .title("My Game")
598 .canvas("game-canvas", 640, 480)
599 .build()
600 .unwrap();
601
602 let css = CssBuilder::new()
603 .variable("main-color", "#ff0000")
604 .reset()
605 .build()
606 .unwrap();
607
608 let js = JsBuilder::new("game.wasm", "game-canvas")
609 .memory(512, 2048)
610 .build()
611 .unwrap();
612
613 let bundle = WebBundle::new(html, css, js);
614 let output = bundle.to_single_file();
615
616 assert!(output.contains("<html lang=\"en\">"));
618 assert!(output.contains("<meta charset=\"UTF-8\">"));
619 assert!(output.contains("<title>My Game</title>"));
620 assert!(output.contains("</html>"));
621
622 assert!(output.contains("<style>"));
624 assert!(output.contains("</style>"));
625
626 assert!(output.contains("<script>"));
628 assert!(output.contains("</script>"));
629 assert!(output.contains("game.wasm"));
630 }
631
632 #[test]
637 fn h0_web_14_web_element_coverage_default() {
638 let elem: WebElementCoverage = Default::default();
639 assert!(elem.id.is_empty());
640 assert_eq!(elem.interaction_count, 0);
641 assert!(elem.interaction_types.is_empty());
642 }
643
644 #[test]
645 fn h0_web_15_rule_coverage_default() {
646 let rule: RuleCoverage = Default::default();
647 assert!(rule.selector.is_empty());
648 assert_eq!(rule.application_count, 0);
649 }
650
651 #[test]
652 fn h0_web_16_function_coverage_default() {
653 let func: FunctionCoverage = Default::default();
654 assert!(func.name.is_empty());
655 assert_eq!(func.execution_count, 0);
656 }
657
658 #[test]
663 fn h0_web_17_coverage_report_all_fields() {
664 let mut coverage = WebAssetCoverage::new();
665
666 coverage.html_elements.insert(
668 "elem".to_string(),
669 WebElementCoverage {
670 id: "elem".to_string(),
671 interaction_count: 1,
672 interaction_types: vec!["click".to_string()],
673 },
674 );
675
676 let report = coverage.report();
677
678 assert_eq!(report.html_coverage, 100.0);
680 assert_eq!(report.css_coverage, 100.0); assert_eq!(report.js_coverage, 100.0); assert_eq!(report.overall_coverage, 100.0);
683 }
684
685 #[test]
690 fn h0_web_18_element_used_creates_new_entry() {
691 let mut coverage = WebAssetCoverage::new();
692
693 assert!(coverage.html_elements.is_empty());
695
696 coverage.element_used("new-elem", "focus");
698
699 assert_eq!(coverage.html_elements.len(), 1);
700 let elem = coverage.html_elements.get("new-elem").unwrap();
701 assert_eq!(elem.id, "new-elem");
702 assert_eq!(elem.interaction_count, 1);
703 assert_eq!(elem.interaction_types, vec!["focus".to_string()]);
704 }
705
706 #[test]
711 fn h0_web_19_web_bundle_clone() {
712 let html = HtmlBuilder::new()
713 .title("Clone Test")
714 .canvas("c", 100, 100)
715 .build()
716 .unwrap();
717
718 let css = CssBuilder::new().build().unwrap();
719 let js = JsBuilder::new("app.wasm", "c").build().unwrap();
720
721 let bundle = WebBundle::new(html, css, js);
722 let cloned = bundle.clone();
723
724 assert_eq!(cloned.html.title, bundle.html.title);
725 assert_eq!(cloned.css.content, bundle.css.content);
726 assert_eq!(cloned.js.content, bundle.js.content);
727 }
728
729 #[test]
730 fn h0_web_20_web_asset_coverage_clone() {
731 let mut coverage = WebAssetCoverage::new();
732 coverage.element_used("test", "click");
733 coverage.rule_applied("#test");
734 coverage.function_executed("main");
735
736 let cloned = coverage.clone();
737
738 assert_eq!(cloned.html_elements.len(), coverage.html_elements.len());
739 assert_eq!(cloned.css_rules.len(), coverage.css_rules.len());
740 assert_eq!(cloned.js_functions.len(), coverage.js_functions.len());
741 }
742
743 #[test]
744 fn h0_web_21_coverage_report_clone() {
745 let report = WebAssetCoverageReport {
746 html_coverage: 75.0,
747 css_coverage: 80.0,
748 js_coverage: 90.0,
749 overall_coverage: 81.67,
750 };
751
752 let cloned = report.clone();
753 assert_eq!(cloned.html_coverage, report.html_coverage);
754 assert_eq!(cloned.css_coverage, report.css_coverage);
755 assert_eq!(cloned.js_coverage, report.js_coverage);
756 assert_eq!(cloned.overall_coverage, report.overall_coverage);
757 }
758
759 #[test]
764 fn h0_web_22_debug_formatting() {
765 let coverage = WebAssetCoverage::new();
766 let debug_str = format!("{:?}", coverage);
767 assert!(debug_str.contains("WebAssetCoverage"));
768
769 let elem_coverage = WebElementCoverage::default();
770 let debug_str = format!("{:?}", elem_coverage);
771 assert!(debug_str.contains("WebElementCoverage"));
772
773 let rule_coverage = RuleCoverage::default();
774 let debug_str = format!("{:?}", rule_coverage);
775 assert!(debug_str.contains("RuleCoverage"));
776
777 let func_coverage = FunctionCoverage::default();
778 let debug_str = format!("{:?}", func_coverage);
779 assert!(debug_str.contains("FunctionCoverage"));
780 }
781
782 #[test]
783 fn h0_web_23_coverage_report_debug() {
784 let report = WebAssetCoverageReport {
785 html_coverage: 100.0,
786 css_coverage: 100.0,
787 js_coverage: 100.0,
788 overall_coverage: 100.0,
789 };
790 let debug_str = format!("{:?}", report);
791 assert!(debug_str.contains("WebAssetCoverageReport"));
792 assert!(debug_str.contains("100"));
793 }
794
795 #[test]
800 fn h0_web_24_coverage_percent_all_covered() {
801 let mut coverage = WebAssetCoverage::new();
802
803 coverage.html_elements.insert(
804 "e".to_string(),
805 WebElementCoverage {
806 id: "e".to_string(),
807 interaction_count: 1,
808 interaction_types: vec![],
809 },
810 );
811
812 coverage.css_rules.insert(
813 "#c".to_string(),
814 RuleCoverage {
815 selector: "#c".to_string(),
816 application_count: 1,
817 },
818 );
819
820 coverage.js_functions.insert(
821 "f".to_string(),
822 FunctionCoverage {
823 name: "f".to_string(),
824 execution_count: 1,
825 },
826 );
827
828 assert_eq!(coverage.coverage_percent(), 100.0);
829 }
830
831 #[test]
832 fn h0_web_25_coverage_percent_none_covered() {
833 let mut coverage = WebAssetCoverage::new();
834
835 coverage.html_elements.insert(
836 "e".to_string(),
837 WebElementCoverage {
838 id: "e".to_string(),
839 interaction_count: 0,
840 interaction_types: vec![],
841 },
842 );
843
844 coverage.css_rules.insert(
845 "#c".to_string(),
846 RuleCoverage {
847 selector: "#c".to_string(),
848 application_count: 0,
849 },
850 );
851
852 coverage.js_functions.insert(
853 "f".to_string(),
854 FunctionCoverage {
855 name: "f".to_string(),
856 execution_count: 0,
857 },
858 );
859
860 assert_eq!(coverage.coverage_percent(), 0.0);
861 }
862
863 #[test]
868 fn h0_web_26_bundle_validation_reflects_html_errors() {
869 let html = GeneratedHtml {
871 title: "Test".to_string(),
872 body_content: String::new(),
873 content: "<html><head><title>Test</title></head><body></body></html>".to_string(),
874 elements: vec![],
875 };
876
877 let css = CssBuilder::new().build().unwrap();
878 let js = JsBuilder::new("app.wasm", "c").build().unwrap();
879
880 let bundle = WebBundle::new(html, css, js);
881
882 assert!(!bundle.is_valid());
884 assert!(!bundle.validation.html.is_valid());
885 }
886
887 #[test]
892 fn h0_web_27_html_coverage_zero_items() {
893 let coverage = WebAssetCoverage::new();
894 let report = coverage.report();
895 assert_eq!(report.html_coverage, 100.0);
897 }
898
899 #[test]
900 fn h0_web_28_css_coverage_zero_items() {
901 let coverage = WebAssetCoverage::new();
902 let report = coverage.report();
903 assert_eq!(report.css_coverage, 100.0);
905 }
906
907 #[test]
908 fn h0_web_29_js_coverage_zero_items() {
909 let coverage = WebAssetCoverage::new();
910 let report = coverage.report();
911 assert_eq!(report.js_coverage, 100.0);
913 }
914
915 #[test]
920 fn h0_web_30_multiple_functions_executed() {
921 let mut coverage = WebAssetCoverage::new();
922
923 coverage.function_executed("init");
924 coverage.function_executed("update");
925 coverage.function_executed("render");
926 coverage.function_executed("init"); assert_eq!(coverage.js_functions.len(), 3);
929 assert_eq!(
930 coverage.js_functions.get("init").unwrap().execution_count,
931 2
932 );
933 assert_eq!(
934 coverage.js_functions.get("update").unwrap().execution_count,
935 1
936 );
937 assert_eq!(
938 coverage.js_functions.get("render").unwrap().execution_count,
939 1
940 );
941 }
942}