(* HTML Test Report Generator *) type test_result = { test_num : int; description : string; input : string; expected : string; actual : string; success : bool; details : (string * string) list; (* Additional key-value pairs *) raw_test_data : string option; (* Original test file content for context *) } type file_result = { filename : string; test_type : string; passed_count : int; failed_count : int; tests : test_result list; } type report = { title : string; test_type : string; description : string; (* Explanation of what this test suite validates *) files : file_result list; total_passed : int; total_failed : int; } let html_escape s = let buf = Buffer.create (String.length s * 2) in String.iter (fun c -> match c with | '&' -> Buffer.add_string buf "&" | '<' -> Buffer.add_string buf "<" | '>' -> Buffer.add_string buf ">" | '"' -> Buffer.add_string buf """ | '\'' -> Buffer.add_string buf "'" | c -> Buffer.add_char buf c ) s; Buffer.contents buf (* No truncation - show full content for standalone reports *) let truncate_string ?(max_len=10000) s = if String.length s <= max_len then s else String.sub s 0 max_len ^ "\n... (truncated at " ^ string_of_int max_len ^ " chars)" let css = {| :root { --bg-primary: #1a1a2e; --bg-secondary: #16213e; --bg-tertiary: #0f3460; --text-primary: #eee; --text-secondary: #aaa; --accent: #e94560; --success: #4ade80; --failure: #f87171; --border: #333; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); line-height: 1.6; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; } header { background: var(--bg-secondary); padding: 20px; border-radius: 8px; margin-bottom: 20px; } header h1 { font-size: 1.5rem; margin-bottom: 10px; color: var(--accent); } .summary { display: flex; gap: 20px; flex-wrap: wrap; align-items: center; } .stat { padding: 8px 16px; border-radius: 6px; font-weight: 600; } .stat.total { background: var(--bg-tertiary); } .stat.passed { background: rgba(74, 222, 128, 0.2); color: var(--success); } .stat.failed { background: rgba(248, 113, 113, 0.2); color: var(--failure); } .controls { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; } input[type="search"], select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; } input[type="search"] { width: 300px; } button { padding: 8px 16px; border: none; border-radius: 6px; background: var(--accent); color: white; cursor: pointer; font-size: 14px; } button:hover { opacity: 0.9; } .sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 280px; background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 10px; padding-top: 20px; } .sidebar-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 14px; } .sidebar-item:hover { background: var(--bg-tertiary); } .sidebar-item.active { background: var(--accent); } .sidebar-item .count { font-size: 12px; padding: 2px 8px; border-radius: 10px; background: var(--bg-primary); } .sidebar-item .count.all-passed { color: var(--success); } .sidebar-item .count.has-failed { color: var(--failure); } main { margin-left: 300px; padding: 20px; padding-top: 30px; } .intro { background: var(--bg-secondary); padding: 20px; border-radius: 8px; margin-bottom: 20px; } .file-section { margin-bottom: 30px; background: var(--bg-secondary); border-radius: 8px; overflow: hidden; } .file-header { padding: 15px 20px; background: var(--bg-tertiary); cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .file-header h2 { font-size: 1.1rem; display: flex; align-items: center; gap: 10px; } .file-header .toggle { font-size: 1.2rem; transition: transform 0.2s; } .file-header.collapsed .toggle { transform: rotate(-90deg); } .file-stats { display: flex; gap: 15px; font-size: 14px; } .file-stats .passed { color: var(--success); } .file-stats .failed { color: var(--failure); } .tests-container { padding: 10px; } .tests-container.hidden { display: none; } .test-item { margin: 8px 0; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } .test-header { padding: 10px 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: var(--bg-primary); } .test-header:hover { background: var(--bg-tertiary); } .test-header .status { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; } .test-header .status.passed { background: var(--success); } .test-header .status.failed { background: var(--failure); } .test-header .test-info { flex: 1; display: flex; align-items: center; } .test-header .test-num { font-weight: 600; margin-right: 10px; color: var(--text-secondary); } .test-header .test-desc { font-size: 14px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 600px; } .test-details { padding: 15px; background: var(--bg-primary); border-top: 1px solid var(--border); display: none; } .test-details.visible { display: block; } .detail-section { margin-bottom: 15px; } .detail-section h4 { font-size: 12px; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 8px; letter-spacing: 0.5px; } .detail-section pre { background: var(--bg-secondary); padding: 12px; border-radius: 6px; overflow-x: auto; font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto; } .detail-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .detail-row.single { grid-template-columns: 1fr; } .meta-info { display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: var(--text-secondary); margin-bottom: 15px; } .meta-info span { background: var(--bg-secondary); padding: 4px 10px; border-radius: 4px; } .diff-indicator { color: var(--failure); font-weight: bold; margin-left: 5px; } @media (max-width: 900px) { .sidebar { display: none; } main { margin-left: 0; } .detail-row { grid-template-columns: 1fr; } } |} let js = {| document.addEventListener('DOMContentLoaded', function() { // File section toggling document.querySelectorAll('.file-header').forEach(header => { header.addEventListener('click', function() { this.classList.toggle('collapsed'); const container = this.nextElementSibling; container.classList.toggle('hidden'); }); }); // Test details toggling document.querySelectorAll('.test-header').forEach(header => { header.addEventListener('click', function(e) { e.stopPropagation(); const details = this.nextElementSibling; details.classList.toggle('visible'); }); }); // Sidebar navigation document.querySelectorAll('.sidebar-item').forEach(item => { item.addEventListener('click', function() { const fileId = this.dataset.file; const section = document.getElementById(fileId); if (section) { section.scrollIntoView({ behavior: 'smooth' }); // Expand if collapsed const header = section.querySelector('.file-header'); if (header.classList.contains('collapsed')) { header.click(); } } // Update active state document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); this.classList.add('active'); }); }); // Search functionality const searchInput = document.getElementById('search'); if (searchInput) { searchInput.addEventListener('input', function() { const query = this.value.toLowerCase(); document.querySelectorAll('.test-item').forEach(item => { const text = item.textContent.toLowerCase(); item.style.display = text.includes(query) ? '' : 'none'; }); }); } // Filter functionality const filterSelect = document.getElementById('filter'); if (filterSelect) { filterSelect.addEventListener('change', function() { const filter = this.value; document.querySelectorAll('.test-item').forEach(item => { const passed = item.querySelector('.status.passed') !== null; if (filter === 'all') { item.style.display = ''; } else if (filter === 'passed') { item.style.display = passed ? '' : 'none'; } else if (filter === 'failed') { item.style.display = passed ? 'none' : ''; } }); }); } // Expand/Collapse all document.getElementById('expand-all')?.addEventListener('click', function() { document.querySelectorAll('.file-header.collapsed').forEach(h => h.click()); }); document.getElementById('collapse-all')?.addEventListener('click', function() { document.querySelectorAll('.file-header:not(.collapsed)').forEach(h => h.click()); }); }); |} let generate_test_html test = let status_class = if test.success then "passed" else "failed" in let desc_escaped = html_escape test.description in let input_escaped = html_escape (truncate_string test.input) in let expected_escaped = html_escape (truncate_string test.expected) in let actual_escaped = html_escape (truncate_string test.actual) in let details_html = String.concat "" (List.map (fun (key, value) -> Printf.sprintf {|

%s

%s
|} (html_escape key) (html_escape value) ) test.details) in let raw_data_html = match test.raw_test_data with | Some data -> Printf.sprintf {|

Original Test Data (from .dat/.test file)

%s
|} (html_escape (truncate_string data)) | None -> "" in let diff_indicator = if test.success then "" else {||} in Printf.sprintf {|
#%d %s
%s

Input (HTML to parse)

%s

Expected Output%s

%s

Actual Output%s

%s
%s
|} test.success status_class test.test_num desc_escaped raw_data_html input_escaped diff_indicator expected_escaped diff_indicator actual_escaped details_html let generate_file_html file = let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in let tests_html = String.concat "\n" (List.map generate_test_html file.tests) in let collapsed = if file.failed_count = 0 then "collapsed" else "" in let hidden = if file.failed_count = 0 then "hidden" else "" in Printf.sprintf {|

%s (%s)

✓ %d passed ✗ %d failed
%s
|} file_id collapsed file.filename file.test_type file.passed_count file.failed_count hidden tests_html let generate_sidebar_html files = String.concat "\n" (List.map (fun file -> let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in let count_class = if file.failed_count = 0 then "all-passed" else "has-failed" in Printf.sprintf {| |} file_id file.filename count_class file.passed_count (file.passed_count + file.failed_count) ) files) let generate_report report output_path = let files_html = String.concat "\n" (List.map generate_file_html report.files) in let sidebar_html = generate_sidebar_html report.files in let html = Printf.sprintf {| %s - Test Report

%s

%s

%d tests ✓ %d passed ✗ %d failed %.1f%% pass rate
%s
|} report.title css sidebar_html report.title (html_escape report.description) (report.total_passed + report.total_failed) report.total_passed report.total_failed (100.0 *. float_of_int report.total_passed /. float_of_int (max 1 (report.total_passed + report.total_failed))) files_html js in let oc = open_out output_path in output_string oc html; close_out oc; Printf.printf "HTML report written to: %s\n" output_path