OCaml HTML5 parser/serialiser based on Python's JustHTML
at validator 26 kB view raw
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 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