OCaml HTML5 parser/serialiser based on Python's JustHTML
1(* HTML Test Report Generator - Standalone HTML reports for test results *)
2
3type test_result = {
4 test_num : int;
5 description : string;
6 input : string;
7 expected : string;
8 actual : string;
9 success : bool;
10 details : (string * string) list; (* Additional key-value pairs *)
11 raw_test_data : string option; (* Original test file content for context *)
12}
13
14type file_result = {
15 filename : string;
16 test_type : string;
17 passed_count : int;
18 failed_count : int;
19 tests : test_result list;
20}
21
22type match_quality_stats = {
23 exact_matches : int;
24 code_matches : int;
25 message_matches : int;
26 substring_matches : int;
27 severity_mismatches : int;
28 no_matches : int;
29 not_applicable : int;
30}
31
32type test_type_stats = {
33 isvalid_passed : int;
34 isvalid_total : int;
35 novalid_passed : int;
36 novalid_total : int;
37 haswarn_passed : int;
38 haswarn_total : int;
39}
40
41type report = {
42 title : string;
43 test_type : string;
44 description : string;
45 files : file_result list;
46 total_passed : int;
47 total_failed : int;
48 match_quality : match_quality_stats option;
49 test_type_breakdown : test_type_stats option;
50 strictness_mode : string option;
51 run_timestamp : string option;
52}
53
54let html_escape s =
55 let buf = Buffer.create (String.length s * 2) in
56 String.iter (fun c ->
57 match c with
58 | '&' -> Buffer.add_string buf "&"
59 | '<' -> Buffer.add_string buf "<"
60 | '>' -> Buffer.add_string buf ">"
61 | '"' -> Buffer.add_string buf """
62 | '\'' -> Buffer.add_string buf "'"
63 | c -> Buffer.add_char buf c
64 ) s;
65 Buffer.contents buf
66
67let truncate_string ?(max_len=10000) s =
68 if String.length s <= max_len then s
69 else String.sub s 0 max_len ^ "\n... (truncated at " ^ string_of_int max_len ^ " chars)"
70
71let css = {|
72:root {
73 --bg-primary: #1a1a2e;
74 --bg-secondary: #16213e;
75 --bg-tertiary: #0f3460;
76 --text-primary: #eee;
77 --text-secondary: #aaa;
78 --text-muted: #666;
79 --accent: #e94560;
80 --accent-light: #ff6b8a;
81 --success: #4ade80;
82 --success-dim: rgba(74, 222, 128, 0.2);
83 --failure: #f87171;
84 --failure-dim: rgba(248, 113, 113, 0.2);
85 --warning: #fbbf24;
86 --info: #60a5fa;
87 --border: #333;
88 --code-bg: #0d1117;
89}
90
91* { box-sizing: border-box; margin: 0; padding: 0; }
92
93body {
94 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
95 background: var(--bg-primary);
96 color: var(--text-primary);
97 line-height: 1.6;
98}
99
100.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
101
102/* Hero Header */
103.hero {
104 background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
105 padding: 40px;
106 border-radius: 12px;
107 margin-bottom: 30px;
108 border: 1px solid var(--border);
109}
110
111.hero h1 {
112 font-size: 2rem;
113 margin-bottom: 15px;
114 color: var(--accent);
115 display: flex;
116 align-items: center;
117 gap: 15px;
118}
119
120.hero h1::before {
121 content: "🧪";
122 font-size: 1.5rem;
123}
124
125.hero-description {
126 color: var(--text-secondary);
127 max-width: 900px;
128 margin-bottom: 20px;
129 font-size: 1.05rem;
130}
131
132.hero-meta {
133 display: flex;
134 gap: 20px;
135 flex-wrap: wrap;
136 font-size: 0.9rem;
137 color: var(--text-muted);
138}
139
140.hero-meta span {
141 display: flex;
142 align-items: center;
143 gap: 6px;
144}
145
146/* Summary Cards */
147.summary-grid {
148 display: grid;
149 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
150 gap: 20px;
151 margin-bottom: 30px;
152}
153
154.summary-card {
155 background: var(--bg-secondary);
156 border-radius: 12px;
157 padding: 24px;
158 border: 1px solid var(--border);
159 text-align: center;
160}
161
162.summary-card.large {
163 grid-column: span 2;
164}
165
166.summary-card h3 {
167 font-size: 0.85rem;
168 text-transform: uppercase;
169 letter-spacing: 1px;
170 color: var(--text-secondary);
171 margin-bottom: 10px;
172}
173
174.summary-card .value {
175 font-size: 2.5rem;
176 font-weight: 700;
177 line-height: 1.2;
178}
179
180.summary-card .value.success { color: var(--success); }
181.summary-card .value.failure { color: var(--failure); }
182.summary-card .value.neutral { color: var(--text-primary); }
183
184.summary-card .subtext {
185 font-size: 0.85rem;
186 color: var(--text-muted);
187 margin-top: 5px;
188}
189
190/* Progress Bar */
191.progress-bar {
192 height: 12px;
193 background: var(--failure-dim);
194 border-radius: 6px;
195 overflow: hidden;
196 margin-top: 15px;
197}
198
199.progress-fill {
200 height: 100%;
201 background: var(--success);
202 border-radius: 6px;
203 transition: width 0.5s ease;
204}
205
206/* Stats Breakdown */
207.stats-section {
208 background: var(--bg-secondary);
209 border-radius: 12px;
210 padding: 24px;
211 margin-bottom: 30px;
212 border: 1px solid var(--border);
213}
214
215.stats-section h2 {
216 font-size: 1.2rem;
217 margin-bottom: 20px;
218 color: var(--accent-light);
219 display: flex;
220 align-items: center;
221 gap: 10px;
222}
223
224.stats-grid {
225 display: grid;
226 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
227 gap: 15px;
228}
229
230.stat-item {
231 background: var(--bg-primary);
232 padding: 16px;
233 border-radius: 8px;
234 display: flex;
235 justify-content: space-between;
236 align-items: center;
237}
238
239.stat-item .label {
240 font-size: 0.9rem;
241 color: var(--text-secondary);
242}
243
244.stat-item .count {
245 font-size: 1.4rem;
246 font-weight: 600;
247}
248
249.stat-item.success .count { color: var(--success); }
250.stat-item.failure .count { color: var(--failure); }
251.stat-item.warning .count { color: var(--warning); }
252.stat-item.info .count { color: var(--info); }
253
254/* Controls */
255.controls {
256 display: flex;
257 gap: 12px;
258 margin-bottom: 25px;
259 flex-wrap: wrap;
260 align-items: center;
261}
262
263input[type="search"], select {
264 padding: 10px 14px;
265 border: 1px solid var(--border);
266 border-radius: 8px;
267 background: var(--bg-secondary);
268 color: var(--text-primary);
269 font-size: 14px;
270}
271
272input[type="search"] { width: 300px; }
273input[type="search"]:focus, select:focus {
274 outline: none;
275 border-color: var(--accent);
276}
277
278button {
279 padding: 10px 18px;
280 border: none;
281 border-radius: 8px;
282 background: var(--accent);
283 color: white;
284 cursor: pointer;
285 font-size: 14px;
286 font-weight: 500;
287 transition: all 0.2s;
288}
289
290button:hover { background: var(--accent-light); }
291button.secondary {
292 background: var(--bg-tertiary);
293 border: 1px solid var(--border);
294}
295button.secondary:hover { background: var(--bg-secondary); }
296
297/* Sidebar */
298.layout {
299 display: grid;
300 grid-template-columns: 280px 1fr;
301 gap: 30px;
302}
303
304.sidebar {
305 position: sticky;
306 top: 20px;
307 height: fit-content;
308 max-height: calc(100vh - 40px);
309 overflow-y: auto;
310 background: var(--bg-secondary);
311 border-radius: 12px;
312 padding: 16px;
313 border: 1px solid var(--border);
314}
315
316.sidebar h3 {
317 font-size: 0.75rem;
318 text-transform: uppercase;
319 letter-spacing: 1px;
320 color: var(--text-muted);
321 margin-bottom: 12px;
322 padding: 0 8px;
323}
324
325.sidebar-item {
326 padding: 10px 12px;
327 border-radius: 8px;
328 cursor: pointer;
329 display: flex;
330 justify-content: space-between;
331 align-items: center;
332 margin-bottom: 4px;
333 font-size: 14px;
334 transition: all 0.2s;
335}
336
337.sidebar-item:hover { background: var(--bg-tertiary); }
338.sidebar-item.active { background: var(--accent); }
339
340.sidebar-item .name {
341 white-space: nowrap;
342 overflow: hidden;
343 text-overflow: ellipsis;
344 max-width: 160px;
345}
346
347.sidebar-item .badge {
348 font-size: 11px;
349 padding: 3px 8px;
350 border-radius: 12px;
351 background: var(--bg-primary);
352 font-weight: 600;
353}
354
355.sidebar-item .badge.all-passed { color: var(--success); }
356.sidebar-item .badge.has-failed { color: var(--failure); }
357
358/* File Sections */
359.file-section {
360 margin-bottom: 24px;
361 background: var(--bg-secondary);
362 border-radius: 12px;
363 overflow: hidden;
364 border: 1px solid var(--border);
365}
366
367.file-header {
368 padding: 18px 24px;
369 background: var(--bg-tertiary);
370 cursor: pointer;
371 display: flex;
372 justify-content: space-between;
373 align-items: center;
374 transition: background 0.2s;
375}
376
377.file-header:hover { background: #1a4a7a; }
378
379.file-header h2 {
380 font-size: 1.1rem;
381 display: flex;
382 align-items: center;
383 gap: 12px;
384}
385
386.file-header .toggle {
387 font-size: 1rem;
388 transition: transform 0.3s;
389 color: var(--text-secondary);
390}
391
392.file-header.collapsed .toggle { transform: rotate(-90deg); }
393
394.file-stats {
395 display: flex;
396 gap: 20px;
397 font-size: 14px;
398}
399
400.file-stats .passed { color: var(--success); font-weight: 500; }
401.file-stats .failed { color: var(--failure); font-weight: 500; }
402
403.tests-container { padding: 12px; }
404.tests-container.hidden { display: none; }
405
406/* Test Items */
407.test-item {
408 margin: 8px 0;
409 border: 1px solid var(--border);
410 border-radius: 8px;
411 overflow: hidden;
412 transition: border-color 0.2s;
413}
414
415.test-item:hover { border-color: var(--text-muted); }
416
417.test-header {
418 padding: 12px 16px;
419 cursor: pointer;
420 display: flex;
421 justify-content: space-between;
422 align-items: center;
423 background: var(--bg-primary);
424 transition: background 0.2s;
425}
426
427.test-header:hover { background: rgba(255,255,255,0.03); }
428
429.test-header .status {
430 width: 10px;
431 height: 10px;
432 border-radius: 50%;
433 margin-right: 12px;
434 flex-shrink: 0;
435}
436
437.test-header .status.passed { background: var(--success); }
438.test-header .status.failed { background: var(--failure); }
439
440.test-header .test-info {
441 flex: 1;
442 display: flex;
443 align-items: center;
444 min-width: 0;
445}
446
447.test-header .test-num {
448 font-weight: 600;
449 margin-right: 12px;
450 color: var(--text-muted);
451 font-size: 0.9rem;
452}
453
454.test-header .test-desc {
455 font-size: 14px;
456 color: var(--text-primary);
457 white-space: nowrap;
458 overflow: hidden;
459 text-overflow: ellipsis;
460}
461
462.test-header .expand-icon {
463 color: var(--text-muted);
464 font-size: 0.8rem;
465}
466
467/* Test Details */
468.test-details {
469 padding: 20px;
470 background: var(--code-bg);
471 border-top: 1px solid var(--border);
472 display: none;
473}
474
475.test-details.visible { display: block; }
476
477.detail-section {
478 margin-bottom: 20px;
479}
480
481.detail-section:last-child { margin-bottom: 0; }
482
483.detail-section h4 {
484 font-size: 11px;
485 text-transform: uppercase;
486 letter-spacing: 1px;
487 color: var(--text-muted);
488 margin-bottom: 10px;
489 display: flex;
490 align-items: center;
491 gap: 8px;
492}
493
494.detail-section pre {
495 background: var(--bg-secondary);
496 padding: 16px;
497 border-radius: 8px;
498 overflow-x: auto;
499 font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
500 font-size: 13px;
501 white-space: pre-wrap;
502 word-break: break-word;
503 max-height: 400px;
504 overflow-y: auto;
505 line-height: 1.5;
506 border: 1px solid var(--border);
507}
508
509.detail-row {
510 display: grid;
511 grid-template-columns: 1fr 1fr;
512 gap: 20px;
513}
514
515.comparison-label {
516 display: inline-block;
517 padding: 2px 8px;
518 border-radius: 4px;
519 font-size: 10px;
520 font-weight: 600;
521 margin-left: 8px;
522}
523
524.comparison-label.match { background: var(--success-dim); color: var(--success); }
525.comparison-label.mismatch { background: var(--failure-dim); color: var(--failure); }
526
527/* Explanation Section */
528.explanation {
529 background: var(--bg-secondary);
530 border-radius: 12px;
531 padding: 24px;
532 margin-bottom: 30px;
533 border: 1px solid var(--border);
534}
535
536.explanation h2 {
537 font-size: 1.2rem;
538 margin-bottom: 16px;
539 color: var(--accent-light);
540}
541
542.explanation p {
543 color: var(--text-secondary);
544 margin-bottom: 12px;
545}
546
547.explanation ul {
548 list-style: none;
549 padding-left: 0;
550}
551
552.explanation li {
553 padding: 8px 0;
554 padding-left: 24px;
555 position: relative;
556 color: var(--text-secondary);
557}
558
559.explanation li::before {
560 content: "→";
561 position: absolute;
562 left: 0;
563 color: var(--accent);
564}
565
566.explanation code {
567 background: var(--bg-primary);
568 padding: 2px 6px;
569 border-radius: 4px;
570 font-family: monospace;
571 font-size: 0.9em;
572 color: var(--accent-light);
573}
574
575@media (max-width: 1000px) {
576 .layout { grid-template-columns: 1fr; }
577 .sidebar { display: none; }
578 .detail-row { grid-template-columns: 1fr; }
579 .summary-card.large { grid-column: span 1; }
580}
581|}
582
583let js = {|
584document.addEventListener('DOMContentLoaded', function() {
585 // File section toggling
586 document.querySelectorAll('.file-header').forEach(header => {
587 header.addEventListener('click', function() {
588 this.classList.toggle('collapsed');
589 const container = this.nextElementSibling;
590 container.classList.toggle('hidden');
591 });
592 });
593
594 // Test details toggling
595 document.querySelectorAll('.test-header').forEach(header => {
596 header.addEventListener('click', function(e) {
597 e.stopPropagation();
598 const details = this.nextElementSibling;
599 details.classList.toggle('visible');
600 const icon = this.querySelector('.expand-icon');
601 if (icon) icon.textContent = details.classList.contains('visible') ? '▲' : '▼';
602 });
603 });
604
605 // Sidebar navigation
606 document.querySelectorAll('.sidebar-item').forEach(item => {
607 item.addEventListener('click', function() {
608 const fileId = this.dataset.file;
609 const section = document.getElementById(fileId);
610 if (section) {
611 section.scrollIntoView({ behavior: 'smooth', block: 'start' });
612 const header = section.querySelector('.file-header');
613 if (header && header.classList.contains('collapsed')) {
614 header.click();
615 }
616 }
617 document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
618 this.classList.add('active');
619 });
620 });
621
622 // Search functionality
623 const searchInput = document.getElementById('search');
624 if (searchInput) {
625 searchInput.addEventListener('input', function() {
626 const query = this.value.toLowerCase();
627 document.querySelectorAll('.test-item').forEach(item => {
628 const text = item.textContent.toLowerCase();
629 item.style.display = text.includes(query) ? '' : 'none';
630 });
631 // Update counts
632 document.querySelectorAll('.file-section').forEach(section => {
633 const visible = section.querySelectorAll('.test-item:not([style*="display: none"])').length;
634 const total = section.querySelectorAll('.test-item').length;
635 if (visible === 0 && query) {
636 section.style.display = 'none';
637 } else {
638 section.style.display = '';
639 }
640 });
641 });
642 }
643
644 // Filter functionality
645 const filterSelect = document.getElementById('filter');
646 if (filterSelect) {
647 filterSelect.addEventListener('change', function() {
648 const filter = this.value;
649 document.querySelectorAll('.test-item').forEach(item => {
650 const passed = item.querySelector('.status.passed') !== null;
651 if (filter === 'all') {
652 item.style.display = '';
653 } else if (filter === 'passed') {
654 item.style.display = passed ? '' : 'none';
655 } else if (filter === 'failed') {
656 item.style.display = passed ? 'none' : '';
657 }
658 });
659 });
660 }
661
662 // Expand/Collapse all
663 document.getElementById('expand-all')?.addEventListener('click', function() {
664 document.querySelectorAll('.file-header.collapsed').forEach(h => h.click());
665 });
666
667 document.getElementById('collapse-all')?.addEventListener('click', function() {
668 document.querySelectorAll('.file-header:not(.collapsed)').forEach(h => h.click());
669 });
670
671 // Show only failed
672 document.getElementById('show-failed')?.addEventListener('click', function() {
673 document.getElementById('filter').value = 'failed';
674 document.getElementById('filter').dispatchEvent(new Event('change'));
675 });
676});
677|}
678
679let generate_test_html test =
680 let status_class = if test.success then "passed" else "failed" in
681 let desc_escaped = html_escape test.description in
682 let input_escaped = html_escape (truncate_string test.input) in
683 let expected_escaped = html_escape (truncate_string test.expected) in
684 let actual_escaped = html_escape (truncate_string test.actual) in
685
686 let details_html = String.concat "" (List.map (fun (key, value) ->
687 Printf.sprintf {|
688 <div class="detail-section">
689 <h4>%s</h4>
690 <pre>%s</pre>
691 </div>
692 |} (html_escape key) (html_escape value)
693 ) test.details) in
694
695 let raw_data_html = match test.raw_test_data with
696 | Some data ->
697 Printf.sprintf {|
698 <div class="detail-section">
699 <h4>📄 Source HTML</h4>
700 <pre>%s</pre>
701 </div>
702 |} (html_escape (truncate_string data))
703 | None -> ""
704 in
705
706 let comparison_label = if test.success then
707 {|<span class="comparison-label match">MATCH</span>|}
708 else
709 {|<span class="comparison-label mismatch">MISMATCH</span>|}
710 in
711
712 Printf.sprintf {|
713 <div class="test-item" data-passed="%b">
714 <div class="test-header">
715 <div class="test-info">
716 <span class="status %s"></span>
717 <span class="test-num">#%d</span>
718 <span class="test-desc">%s</span>
719 </div>
720 <span class="expand-icon">▼</span>
721 </div>
722 <div class="test-details">
723 %s
724 <div class="detail-section">
725 <h4>📥 Input File</h4>
726 <pre>%s</pre>
727 </div>
728 <div class="detail-row">
729 <div class="detail-section">
730 <h4>✓ Expected Output</h4>
731 <pre>%s</pre>
732 </div>
733 <div class="detail-section">
734 <h4>⚡ Actual Output %s</h4>
735 <pre>%s</pre>
736 </div>
737 </div>
738 %s
739 </div>
740 </div>
741 |} test.success status_class test.test_num desc_escaped
742 raw_data_html input_escaped expected_escaped comparison_label actual_escaped details_html
743
744let generate_file_html file =
745 let file_id = String.map (fun c -> if c = '/' || c = '.' then '-' else c) file.filename in
746 let tests_html = String.concat "\n" (List.map generate_test_html file.tests) in
747 let collapsed = if file.failed_count = 0 then "collapsed" else "" in
748 let hidden = if file.failed_count = 0 then "hidden" else "" in
749
750 Printf.sprintf {|
751 <div class="file-section" id="file-%s">
752 <div class="file-header %s">
753 <h2>
754 <span class="toggle">▼</span>
755 📁 %s
756 </h2>
757 <div class="file-stats">
758 <span class="passed">✓ %d passed</span>
759 <span class="failed">✗ %d failed</span>
760 </div>
761 </div>
762 <div class="tests-container %s">
763 %s
764 </div>
765 </div>
766 |} file_id collapsed file.filename file.passed_count file.failed_count hidden tests_html
767
768let generate_sidebar_html files =
769 String.concat "\n" (List.map (fun file ->
770 let file_id = String.map (fun c -> if c = '/' || c = '.' then '-' else c) file.filename in
771 let badge_class = if file.failed_count = 0 then "all-passed" else "has-failed" in
772 Printf.sprintf {|
773 <div class="sidebar-item" data-file="file-%s">
774 <span class="name">%s</span>
775 <span class="badge %s">%d/%d</span>
776 </div>
777 |} file_id file.filename badge_class file.passed_count (file.passed_count + file.failed_count)
778 ) files)
779
780let generate_match_quality_html stats =
781 Printf.sprintf {|
782 <div class="stats-section">
783 <h2>📊 Match Quality Breakdown</h2>
784 <div class="stats-grid">
785 <div class="stat-item success">
786 <span class="label">Exact Matches</span>
787 <span class="count">%d</span>
788 </div>
789 <div class="stat-item success">
790 <span class="label">Code Matches</span>
791 <span class="count">%d</span>
792 </div>
793 <div class="stat-item info">
794 <span class="label">Message Matches</span>
795 <span class="count">%d</span>
796 </div>
797 <div class="stat-item warning">
798 <span class="label">Substring Matches</span>
799 <span class="count">%d</span>
800 </div>
801 <div class="stat-item warning">
802 <span class="label">Severity Mismatches</span>
803 <span class="count">%d</span>
804 </div>
805 <div class="stat-item failure">
806 <span class="label">No Matches</span>
807 <span class="count">%d</span>
808 </div>
809 <div class="stat-item">
810 <span class="label">N/A (isvalid tests)</span>
811 <span class="count">%d</span>
812 </div>
813 </div>
814 </div>
815 |} stats.exact_matches stats.code_matches stats.message_matches
816 stats.substring_matches stats.severity_mismatches stats.no_matches stats.not_applicable
817
818let generate_test_type_html stats =
819 let pct a b = if b = 0 then 0.0 else 100.0 *. float_of_int a /. float_of_int b in
820 Printf.sprintf {|
821 <div class="stats-section">
822 <h2>📋 Results by Test Type</h2>
823 <div class="stats-grid">
824 <div class="stat-item %s">
825 <span class="label">isvalid (no errors expected)</span>
826 <span class="count">%d/%d (%.1f%%)</span>
827 </div>
828 <div class="stat-item %s">
829 <span class="label">novalid (errors expected)</span>
830 <span class="count">%d/%d (%.1f%%)</span>
831 </div>
832 <div class="stat-item %s">
833 <span class="label">haswarn (warnings expected)</span>
834 <span class="count">%d/%d (%.1f%%)</span>
835 </div>
836 </div>
837 </div>
838 |}
839 (if stats.isvalid_passed = stats.isvalid_total then "success" else "failure")
840 stats.isvalid_passed stats.isvalid_total (pct stats.isvalid_passed stats.isvalid_total)
841 (if stats.novalid_passed = stats.novalid_total then "success" else "failure")
842 stats.novalid_passed stats.novalid_total (pct stats.novalid_passed stats.novalid_total)
843 (if stats.haswarn_passed = stats.haswarn_total then "success" else "failure")
844 stats.haswarn_passed stats.haswarn_total (pct stats.haswarn_passed stats.haswarn_total)
845
846let generate_report report output_path =
847 let files_html = String.concat "\n" (List.map generate_file_html report.files) in
848 let sidebar_html = generate_sidebar_html report.files in
849 let total = report.total_passed + report.total_failed in
850 let pass_rate = if total = 0 then 0.0 else 100.0 *. float_of_int report.total_passed /. float_of_int total in
851
852 let match_quality_html = match report.match_quality with
853 | Some stats -> generate_match_quality_html stats
854 | None -> ""
855 in
856
857 let test_type_html = match report.test_type_breakdown with
858 | Some stats -> generate_test_type_html stats
859 | None -> ""
860 in
861
862 let mode_text = match report.strictness_mode with
863 | Some m -> Printf.sprintf " (Mode: %s)" m
864 | None -> ""
865 in
866
867 let timestamp_text = match report.run_timestamp with
868 | Some t -> Printf.sprintf "<span>🕐 %s</span>" (html_escape t)
869 | None -> ""
870 in
871
872 let html = Printf.sprintf {|<!DOCTYPE html>
873<html lang="en">
874<head>
875 <meta charset="UTF-8">
876 <meta name="viewport" content="width=device-width, initial-scale=1.0">
877 <title>%s - Test Report</title>
878 <style>%s</style>
879</head>
880<body>
881 <div class="container">
882 <div class="hero">
883 <h1>%s</h1>
884 <p class="hero-description">%s</p>
885 <div class="hero-meta">
886 <span>📊 %d total tests</span>
887 <span>✓ %d passed</span>
888 <span>✗ %d failed</span>
889 %s
890 </div>
891 </div>
892
893 <div class="summary-grid">
894 <div class="summary-card large">
895 <h3>Overall Pass Rate%s</h3>
896 <div class="value %s">%.1f%%</div>
897 <div class="progress-bar">
898 <div class="progress-fill" style="width: %.1f%%"></div>
899 </div>
900 </div>
901 <div class="summary-card">
902 <h3>Tests Passed</h3>
903 <div class="value success">%d</div>
904 <div class="subtext">out of %d tests</div>
905 </div>
906 <div class="summary-card">
907 <h3>Tests Failed</h3>
908 <div class="value %s">%d</div>
909 <div class="subtext">%s</div>
910 </div>
911 <div class="summary-card">
912 <h3>Categories</h3>
913 <div class="value neutral">%d</div>
914 <div class="subtext">test categories</div>
915 </div>
916 </div>
917
918 %s
919 %s
920
921 <div class="explanation">
922 <h2>📖 About This Test Run</h2>
923 <p>This report shows the results of running the <strong>%s</strong> test suite against the HTML5 validator implementation.</p>
924 <p>Tests are organized by category and classified by their expected outcome:</p>
925 <ul>
926 <li><code>-isvalid.html</code> — Valid HTML that should produce <strong>no errors or warnings</strong></li>
927 <li><code>-novalid.html</code> — Invalid HTML that should produce <strong>at least one error</strong></li>
928 <li><code>-haswarn.html</code> — HTML that should produce <strong>at least one warning</strong></li>
929 </ul>
930 <p>Click on any test to expand its details and see the input HTML, expected output, and actual validator messages.</p>
931 </div>
932
933 <div class="controls">
934 <input type="search" id="search" placeholder="🔍 Search tests by name or content...">
935 <select id="filter">
936 <option value="all">All tests</option>
937 <option value="passed">Passed only</option>
938 <option value="failed">Failed only</option>
939 </select>
940 <button id="show-failed" class="secondary">Show Failed Only</button>
941 <button id="expand-all" class="secondary">Expand All</button>
942 <button id="collapse-all" class="secondary">Collapse All</button>
943 </div>
944
945 <div class="layout">
946 <div class="sidebar">
947 <h3>Categories</h3>
948 %s
949 </div>
950 <div class="main-content">
951 %s
952 </div>
953 </div>
954 </div>
955
956 <script>%s</script>
957</body>
958</html>
959|} report.title css
960 report.title (html_escape report.description)
961 total report.total_passed report.total_failed timestamp_text
962 mode_text
963 (if pass_rate >= 99.0 then "success" else if pass_rate >= 90.0 then "neutral" else "failure")
964 pass_rate pass_rate
965 report.total_passed total
966 (if report.total_failed = 0 then "success" else "failure")
967 report.total_failed
968 (if report.total_failed = 0 then "Perfect score!" else "needs attention")
969 (List.length report.files)
970 test_type_html match_quality_html
971 report.title
972 sidebar_html files_html js
973 in
974
975 let oc = open_out output_path in
976 output_string oc html;
977 close_out oc;
978 Printf.printf "HTML report written to: %s\n" output_path