Skip to main content

jugar_probar/web/
mod.rs

1//! Zero-JavaScript Web Asset Generation (Advanced Feature E)
2//!
3//! Programmatic HTML, CSS, and minimal JavaScript generation with full
4//! validation and coverage tracking. Follows the Zero-JavaScript Policy:
5//! - HTML/CSS/JS are generated programmatically, never written by hand
6//! - JavaScript is limited to under 20 lines (WASM loader only)
7//! - All generated assets are linted and validated
8//! - Coverage tracking for generated web assets
9
10mod 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/// Complete web asset bundle generated by Probar
24#[derive(Debug, Clone)]
25pub struct WebBundle {
26    /// Generated HTML document
27    pub html: GeneratedHtml,
28    /// Generated CSS stylesheet
29    pub css: GeneratedCss,
30    /// Generated JavaScript (minimal WASM loader)
31    pub js: GeneratedJs,
32    /// Validation report for all assets
33    pub validation: ValidationReport,
34}
35
36impl WebBundle {
37    /// Create a new web bundle from components
38    #[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    /// Check if bundle passes all validation
50    #[must_use]
51    pub fn is_valid(&self) -> bool {
52        self.validation.is_valid()
53    }
54
55    /// Get the complete HTML document with embedded CSS and JS
56    #[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/// Coverage tracking for generated web assets
85#[derive(Debug, Clone, Default)]
86pub struct WebAssetCoverage {
87    /// HTML element coverage
88    pub html_elements: std::collections::HashMap<String, WebElementCoverage>,
89    /// CSS rule coverage
90    pub css_rules: std::collections::HashMap<String, RuleCoverage>,
91    /// JS function coverage
92    pub js_functions: std::collections::HashMap<String, FunctionCoverage>,
93}
94
95/// Coverage data for an HTML element
96#[derive(Debug, Clone, Default)]
97pub struct WebElementCoverage {
98    /// Element ID
99    pub id: String,
100    /// Number of interactions
101    pub interaction_count: u64,
102    /// Types of interactions
103    pub interaction_types: Vec<String>,
104}
105
106/// Coverage data for a CSS rule
107#[derive(Debug, Clone, Default)]
108pub struct RuleCoverage {
109    /// CSS selector
110    pub selector: String,
111    /// Number of times rule was applied
112    pub application_count: u64,
113}
114
115/// Coverage data for a JS function
116#[derive(Debug, Clone, Default)]
117pub struct FunctionCoverage {
118    /// Function name
119    pub name: String,
120    /// Number of executions
121    pub execution_count: u64,
122}
123
124impl WebAssetCoverage {
125    /// Create new coverage tracker
126    #[must_use]
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Record element interaction
132    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    /// Record CSS rule application
145    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    /// Record JS function execution
152    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    /// Calculate overall coverage percentage
159    #[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    /// Generate coverage report
188    #[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/// Coverage report for web assets
242#[derive(Debug, Clone)]
243pub struct WebAssetCoverageReport {
244    /// HTML element coverage percentage
245    pub html_coverage: f64,
246    /// CSS rule coverage percentage
247    pub css_coverage: f64,
248    /// JS function coverage percentage
249    pub js_coverage: f64,
250    /// Overall coverage percentage
251    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    // =========================================================================
260    // H₀-WEB-01: WebBundle creation and validation
261    // =========================================================================
262
263    #[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    // =========================================================================
300    // H₀-WEB-03: WebAssetCoverage tracking
301    // =========================================================================
302
303    #[test]
304    fn h0_web_03_coverage_tracking() {
305        let mut coverage = WebAssetCoverage::new();
306
307        // Register elements
308        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        // Initially 0% coverage
326        assert_eq!(coverage.coverage_percent(), 0.0);
327
328        // Use element
329        coverage.element_used("button", "click");
330        assert!(coverage.coverage_percent() > 0.0);
331
332        // Apply CSS rule
333        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); // No CSS rules = 100%
362        assert_eq!(report.js_coverage, 100.0); // No JS functions = 100%
363    }
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    // =========================================================================
372    // H₀-WEB-06: WebAssetCoverage function_executed
373    // =========================================================================
374
375    #[test]
376    fn h0_web_06_function_executed_tracking() {
377        let mut coverage = WebAssetCoverage::new();
378
379        // Execute a function for the first time
380        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        // Execute again - should increment count
388        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        // Add two JS functions - one executed, one not
398        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    // =========================================================================
418    // H₀-WEB-08: Element interaction type deduplication
419    // =========================================================================
420
421    #[test]
422    fn h0_web_08_element_used_duplicate_interaction_type() {
423        let mut coverage = WebAssetCoverage::new();
424
425        // Use element with same interaction type multiple times
426        coverage.element_used("btn", "click");
427        coverage.element_used("btn", "click"); // Same type
428        coverage.element_used("btn", "hover"); // Different type
429
430        let elem = coverage.html_elements.get("btn").unwrap();
431        assert_eq!(elem.interaction_count, 3);
432        // Should only have 2 unique interaction types
433        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    // =========================================================================
439    // H₀-WEB-09: Rule applied tracking
440    // =========================================================================
441
442    #[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    // =========================================================================
456    // H₀-WEB-10: CSS coverage with partial coverage
457    // =========================================================================
458
459    #[test]
460    fn h0_web_10_css_coverage_partial() {
461        let mut coverage = WebAssetCoverage::new();
462
463        // Add 4 CSS rules, only 1 applied
464        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    // =========================================================================
498    // H₀-WEB-11: Combined coverage calculation
499    // =========================================================================
500
501    #[test]
502    fn h0_web_11_overall_coverage_mixed() {
503        let mut coverage = WebAssetCoverage::new();
504
505        // 1 out of 2 HTML elements covered
506        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        // 1 out of 2 CSS rules covered
524        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        // 1 out of 2 JS functions covered
540        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        // 3 out of 6 total = 50%
556        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    // =========================================================================
566    // H₀-WEB-12: WebBundle with invalid JS
567    // =========================================================================
568
569    #[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        // Create JS with security issue
580        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    // =========================================================================
591    // H₀-WEB-13: WebBundle to_single_file content verification
592    // =========================================================================
593
594    #[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        // Check HTML structure
617        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        // Check CSS is embedded
623        assert!(output.contains("<style>"));
624        assert!(output.contains("</style>"));
625
626        // Check JS is embedded
627        assert!(output.contains("<script>"));
628        assert!(output.contains("</script>"));
629        assert!(output.contains("game.wasm"));
630    }
631
632    // =========================================================================
633    // H₀-WEB-14: Default trait implementations
634    // =========================================================================
635
636    #[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    // =========================================================================
659    // H₀-WEB-17: WebAssetCoverageReport fields
660    // =========================================================================
661
662    #[test]
663    fn h0_web_17_coverage_report_all_fields() {
664        let mut coverage = WebAssetCoverage::new();
665
666        // Setup with known values for predictable report
667        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        // Verify all fields are accessible
679        assert_eq!(report.html_coverage, 100.0);
680        assert_eq!(report.css_coverage, 100.0); // Empty = 100%
681        assert_eq!(report.js_coverage, 100.0); // Empty = 100%
682        assert_eq!(report.overall_coverage, 100.0);
683    }
684
685    // =========================================================================
686    // H₀-WEB-18: Element used on new element
687    // =========================================================================
688
689    #[test]
690    fn h0_web_18_element_used_creates_new_entry() {
691        let mut coverage = WebAssetCoverage::new();
692
693        // Element doesn't exist yet
694        assert!(coverage.html_elements.is_empty());
695
696        // Using it creates an entry
697        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    // =========================================================================
707    // H₀-WEB-19: Clone and Debug traits
708    // =========================================================================
709
710    #[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    // =========================================================================
760    // H₀-WEB-22: Debug formatting
761    // =========================================================================
762
763    #[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    // =========================================================================
796    // H₀-WEB-24: Edge cases for coverage percent
797    // =========================================================================
798
799    #[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    // =========================================================================
864    // H₀-WEB-26: Verify bundle validation uses WebValidator
865    // =========================================================================
866
867    #[test]
868    fn h0_web_26_bundle_validation_reflects_html_errors() {
869        // Create HTML manually with missing DOCTYPE
870        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        // Validation should fail due to missing DOCTYPE
883        assert!(!bundle.is_valid());
884        assert!(!bundle.validation.html.is_valid());
885    }
886
887    // =========================================================================
888    // H₀-WEB-27: Test individual coverage calculation methods
889    // =========================================================================
890
891    #[test]
892    fn h0_web_27_html_coverage_zero_items() {
893        let coverage = WebAssetCoverage::new();
894        let report = coverage.report();
895        // Empty = 100% (no items to cover)
896        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        // Empty = 100% (no items to cover)
904        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        // Empty = 100% (no items to cover)
912        assert_eq!(report.js_coverage, 100.0);
913    }
914
915    // =========================================================================
916    // H₀-WEB-30: Multiple function execution tracking
917    // =========================================================================
918
919    #[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"); // Call init again
927
928        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}