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 "&amp;" 59 | '<' -> Buffer.add_string buf "&lt;" 60 | '>' -> Buffer.add_string buf "&gt;" 61 | '"' -> Buffer.add_string buf "&quot;" 62 | '\'' -> Buffer.add_string buf "&#x27;" 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 let escaped_full = html_escape file.filename in 750 751 Printf.sprintf {| 752 <div class="file-section" id="file-%s"> 753 <div class="file-header %s"> 754 <h2 title="%s"> 755 <span class="toggle">▼</span> 756 📁 %s 757 </h2> 758 <div class="file-stats"> 759 <span class="passed">✓ %d passed</span> 760 <span class="failed">✗ %d failed</span> 761 </div> 762 </div> 763 <div class="tests-container %s"> 764 %s 765 </div> 766 </div> 767 |} file_id collapsed escaped_full file.filename file.passed_count file.failed_count hidden tests_html 768 769let shorten_filename name = 770 (* Shorten common prefixes for display, keep full name for tooltip *) 771 let short = 772 if String.length name > 10 && String.sub name 0 10 = "HTML5lib /" then 773 "H5:" ^ String.sub name 10 (String.length name - 10) 774 else if String.length name > 12 && String.sub name 0 12 = "Validator / " then 775 "VA:" ^ String.sub name 12 (String.length name - 12) 776 else name 777 in 778 String.trim short 779 780let generate_sidebar_html files = 781 String.concat "\n" (List.map (fun file -> 782 let file_id = String.map (fun c -> if c = '/' || c = '.' then '-' else c) file.filename in 783 let badge_class = if file.failed_count = 0 then "all-passed" else "has-failed" in 784 let short_name = shorten_filename file.filename in 785 let escaped_full = html_escape file.filename in 786 Printf.sprintf {| 787 <div class="sidebar-item" data-file="file-%s" title="%s"> 788 <span class="name">%s</span> 789 <span class="badge %s">%d/%d</span> 790 </div> 791 |} file_id escaped_full short_name badge_class file.passed_count (file.passed_count + file.failed_count) 792 ) files) 793 794let generate_match_quality_html stats = 795 Printf.sprintf {| 796 <div class="stats-section"> 797 <h2>📊 Match Quality Breakdown</h2> 798 <div class="stats-grid"> 799 <div class="stat-item success"> 800 <span class="label">Exact Matches</span> 801 <span class="count">%d</span> 802 </div> 803 <div class="stat-item success"> 804 <span class="label">Code Matches</span> 805 <span class="count">%d</span> 806 </div> 807 <div class="stat-item info"> 808 <span class="label">Message Matches</span> 809 <span class="count">%d</span> 810 </div> 811 <div class="stat-item warning"> 812 <span class="label">Substring Matches</span> 813 <span class="count">%d</span> 814 </div> 815 <div class="stat-item warning"> 816 <span class="label">Severity Mismatches</span> 817 <span class="count">%d</span> 818 </div> 819 <div class="stat-item failure"> 820 <span class="label">No Matches</span> 821 <span class="count">%d</span> 822 </div> 823 <div class="stat-item"> 824 <span class="label">N/A (isvalid tests)</span> 825 <span class="count">%d</span> 826 </div> 827 </div> 828 </div> 829 |} stats.exact_matches stats.code_matches stats.message_matches 830 stats.substring_matches stats.severity_mismatches stats.no_matches stats.not_applicable 831 832let generate_test_type_html stats = 833 let pct a b = if b = 0 then 0.0 else 100.0 *. float_of_int a /. float_of_int b in 834 Printf.sprintf {| 835 <div class="stats-section"> 836 <h2>📋 Results by Test Type</h2> 837 <div class="stats-grid"> 838 <div class="stat-item %s"> 839 <span class="label">isvalid (no errors expected)</span> 840 <span class="count">%d/%d (%.1f%%)</span> 841 </div> 842 <div class="stat-item %s"> 843 <span class="label">novalid (errors expected)</span> 844 <span class="count">%d/%d (%.1f%%)</span> 845 </div> 846 <div class="stat-item %s"> 847 <span class="label">haswarn (warnings expected)</span> 848 <span class="count">%d/%d (%.1f%%)</span> 849 </div> 850 </div> 851 </div> 852 |} 853 (if stats.isvalid_passed = stats.isvalid_total then "success" else "failure") 854 stats.isvalid_passed stats.isvalid_total (pct stats.isvalid_passed stats.isvalid_total) 855 (if stats.novalid_passed = stats.novalid_total then "success" else "failure") 856 stats.novalid_passed stats.novalid_total (pct stats.novalid_passed stats.novalid_total) 857 (if stats.haswarn_passed = stats.haswarn_total then "success" else "failure") 858 stats.haswarn_passed stats.haswarn_total (pct stats.haswarn_passed stats.haswarn_total) 859 860let generate_report report output_path = 861 let files_html = String.concat "\n" (List.map generate_file_html report.files) in 862 let sidebar_html = generate_sidebar_html report.files in 863 let total = report.total_passed + report.total_failed in 864 let pass_rate = if total = 0 then 0.0 else 100.0 *. float_of_int report.total_passed /. float_of_int total in 865 866 let match_quality_html = match report.match_quality with 867 | Some stats -> generate_match_quality_html stats 868 | None -> "" 869 in 870 871 let test_type_html = match report.test_type_breakdown with 872 | Some stats -> generate_test_type_html stats 873 | None -> "" 874 in 875 876 let mode_text = match report.strictness_mode with 877 | Some m -> Printf.sprintf " (Mode: %s)" m 878 | None -> "" 879 in 880 881 let timestamp_text = match report.run_timestamp with 882 | Some t -> Printf.sprintf "<span>🕐 %s</span>" (html_escape t) 883 | None -> "" 884 in 885 886 let html = Printf.sprintf {|<!DOCTYPE html> 887<html lang="en"> 888<head> 889 <meta charset="UTF-8"> 890 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 891 <title>%s - Test Report</title> 892 <style>%s</style> 893</head> 894<body> 895 <div class="container"> 896 <div class="hero"> 897 <h1>%s</h1> 898 <p class="hero-description">%s</p> 899 <div class="hero-meta"> 900 <span>📊 %d total tests</span> 901 <span>✓ %d passed</span> 902 <span>✗ %d failed</span> 903 %s 904 </div> 905 </div> 906 907 <div class="summary-grid"> 908 <div class="summary-card large"> 909 <h3>Overall Pass Rate%s</h3> 910 <div class="value %s">%.1f%%</div> 911 <div class="progress-bar"> 912 <div class="progress-fill" style="width: %.1f%%"></div> 913 </div> 914 </div> 915 <div class="summary-card"> 916 <h3>Tests Passed</h3> 917 <div class="value success">%d</div> 918 <div class="subtext">out of %d tests</div> 919 </div> 920 <div class="summary-card"> 921 <h3>Tests Failed</h3> 922 <div class="value %s">%d</div> 923 <div class="subtext">%s</div> 924 </div> 925 <div class="summary-card"> 926 <h3>Categories</h3> 927 <div class="value neutral">%d</div> 928 <div class="subtext">test categories</div> 929 </div> 930 </div> 931 932 %s 933 %s 934 935 <div class="explanation"> 936 <h2>📖 About This Test Run</h2> 937 <p>This report shows the results of running the <strong>%s</strong> test suite against the HTML5 validator implementation.</p> 938 <p>Tests are organized by category and classified by their expected outcome:</p> 939 <ul> 940 <li><code>-isvalid.html</code> — Valid HTML that should produce <strong>no errors or warnings</strong></li> 941 <li><code>-novalid.html</code> — Invalid HTML that should produce <strong>at least one error</strong></li> 942 <li><code>-haswarn.html</code> — HTML that should produce <strong>at least one warning</strong></li> 943 </ul> 944 <p>Click on any test to expand its details and see the input HTML, expected output, and actual validator messages.</p> 945 </div> 946 947 <div class="controls"> 948 <input type="search" id="search" placeholder="🔍 Search tests by name or content..."> 949 <select id="filter"> 950 <option value="all">All tests</option> 951 <option value="passed">Passed only</option> 952 <option value="failed">Failed only</option> 953 </select> 954 <button id="show-failed" class="secondary">Show Failed Only</button> 955 <button id="expand-all" class="secondary">Expand All</button> 956 <button id="collapse-all" class="secondary">Collapse All</button> 957 </div> 958 959 <div class="layout"> 960 <div class="sidebar"> 961 <h3>Categories</h3> 962 %s 963 </div> 964 <div class="main-content"> 965 %s 966 </div> 967 </div> 968 </div> 969 970 <script>%s</script> 971</body> 972</html> 973|} report.title css 974 report.title report.description (* description may contain HTML *) 975 total report.total_passed report.total_failed timestamp_text 976 mode_text 977 (if pass_rate >= 99.0 then "success" else if pass_rate >= 90.0 then "neutral" else "failure") 978 pass_rate pass_rate 979 report.total_passed total 980 (if report.total_failed = 0 then "success" else "failure") 981 report.total_failed 982 (if report.total_failed = 0 then "Perfect score!" else "needs attention") 983 (List.length report.files) 984 test_type_html match_quality_html 985 report.title 986 sidebar_html files_html js 987 in 988 989 let oc = open_out output_path in 990 output_string oc html; 991 close_out oc; 992 Printf.printf "HTML report written to: %s\n" output_path