OCaml HTML5 parser/serialiser based on Python's JustHTML
at main 30 kB view raw
1(** Test runner for Nu HTML Validator test suite 2 3 This validates HTML5 documents against the upstream Nu HTML Validator test suite. 4 Tests are classified by filename suffix: 5 - `-isvalid.html` : Should produce no errors or warnings 6 - `-novalid.html` : Should produce at least one error 7 - `-haswarn.html` : Should produce at least one warning 8*) 9 10module Report = Test_report 11 12type expected_outcome = 13 | Valid (** -isvalid.html: expect no errors *) 14 | Invalid (** -novalid.html: expect error matching messages.json *) 15 | HasWarning (** -haswarn.html: expect warning matching messages.json *) 16 | Unknown (** Unknown suffix *) 17 18type test_file = { 19 path : string; (** Full filesystem path *) 20 relative_path : string; (** Path relative to tests/, used as key in messages.json *) 21 category : string; (** html, html-aria, etc. *) 22 expected : expected_outcome; 23} 24 25type test_result = { 26 file : test_file; 27 passed : bool; 28 actual_errors : string list; 29 actual_warnings : string list; 30 actual_infos : string list; 31 expected_message : string option; 32 match_quality : Expected_message.match_quality option; (** How well did message match? *) 33 details : string; 34} 35 36(** Parse expected outcome from filename suffix *) 37let parse_outcome filename = 38 (* Check for .html suffix *) 39 if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-isvalid.html" then 40 Valid 41 else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-novalid.html" then 42 Invalid 43 else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-haswarn.html" then 44 HasWarning 45 (* Check for .xhtml suffix *) 46 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-isvalid.xhtml" then 47 Valid 48 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-novalid.xhtml" then 49 Invalid 50 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-haswarn.xhtml" then 51 HasWarning 52 else 53 Unknown 54 55(** Current strictness setting - can be set via --strict flag *) 56let strictness = ref Expected_message.lenient 57 58(** Find best matching message and return (found_acceptable, best_quality) *) 59let find_best_match ~expected_str ~actual_msgs = 60 let expected = Expected_message.parse expected_str in 61 let qualities = List.map (fun msg -> 62 Expected_message.matches ~strictness:!strictness ~expected ~actual:msg 63 ) actual_msgs in 64 65 let best_quality = 66 List.fold_left (fun best q -> 67 (* Lower variant = better match in our type definition *) 68 if q < best then q else best 69 ) Expected_message.No_match qualities 70 in 71 let acceptable = Expected_message.is_acceptable ~strictness:!strictness best_quality in 72 (acceptable, best_quality) 73 74(** Recursively find all HTML test files *) 75let rec discover_tests_in_dir base_dir current_dir = 76 let full_path = Filename.concat base_dir current_dir in 77 if not (Sys.file_exists full_path) then [] 78 else if Sys.is_directory full_path then begin 79 let entries = Sys.readdir full_path |> Array.to_list in 80 List.concat_map (fun entry -> 81 let sub_path = if current_dir = "" then entry else Filename.concat current_dir entry in 82 discover_tests_in_dir base_dir sub_path 83 ) entries 84 end else if Filename.check_suffix current_dir ".html" || Filename.check_suffix current_dir ".xhtml" then begin 85 let outcome = parse_outcome (Filename.basename current_dir) in 86 if outcome = Unknown then [] 87 else 88 let category = 89 match String.split_on_char '/' current_dir with 90 | cat :: _ -> cat 91 | [] -> "unknown" 92 in 93 [{ path = full_path; relative_path = current_dir; category; expected = outcome }] 94 end else 95 [] 96 97let discover_tests tests_dir = 98 discover_tests_in_dir tests_dir "" 99 100(** Run a single test *) 101let run_test messages test = 102 try 103 let ic = open_in test.path in 104 let content = really_input_string ic (in_channel_length ic) in 105 close_in ic; 106 107 let reader = Bytesrw.Bytes.Reader.of_string content in 108 let result = Htmlrw_check.check ~collect_parse_errors:true ~system_id:test.relative_path reader in 109 110 (* Keep full message objects for proper matching *) 111 let error_msgs = Htmlrw_check.errors result in 112 let warning_msgs = Htmlrw_check.warnings result in 113 let info_msgs = Htmlrw_check.infos result in 114 115 (* Extract text for reporting *) 116 let errors = List.map (fun m -> m.Htmlrw_check.text) error_msgs in 117 let warnings = List.map (fun m -> m.Htmlrw_check.text) warning_msgs in 118 let infos = List.map (fun m -> m.Htmlrw_check.text) info_msgs in 119 let expected_msg = Validator_messages.get messages test.relative_path in 120 121 let (passed, match_quality, details) = match test.expected with 122 | Valid -> 123 (* isvalid tests fail on errors or warnings, but info messages are OK *) 124 if errors = [] && warnings = [] then 125 (true, None, 126 if infos = [] then "OK: No messages" else Printf.sprintf "OK: No errors/warnings (%d info)" (List.length infos)) 127 else 128 (false, None, 129 Printf.sprintf "Expected valid but got %d errors, %d warnings" 130 (List.length errors) (List.length warnings)) 131 | Invalid -> 132 if errors = [] then 133 (false, None, "Expected error but got none") 134 else begin 135 (* For novalid tests, require message match when expected message is provided *) 136 match expected_msg with 137 | None -> 138 (* No expected message - pass if any error detected *) 139 (true, None, 140 Printf.sprintf "Got %d error(s), no expected message to match" (List.length errors)) 141 | Some exp -> 142 let (matched, quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in 143 if matched then 144 (true, Some quality, 145 Printf.sprintf "Got %d error(s), match: %s" (List.length errors) 146 (Expected_message.match_quality_to_string quality)) 147 else 148 (* FAIL if message doesn't match *) 149 (false, Some quality, 150 Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got: %s" 151 (Expected_message.match_quality_to_string quality) 152 exp (String.concat "\n " errors)) 153 end 154 | HasWarning -> 155 (* For haswarn, require message match against warnings or infos *) 156 let all_msgs = warning_msgs @ info_msgs in 157 let all_messages = warnings @ infos in 158 if all_messages = [] && errors = [] then 159 (false, None, "Expected warning but got none") 160 else begin 161 match expected_msg with 162 | None -> 163 if all_messages <> [] then 164 (true, None, Printf.sprintf "Got %d warning/info message(s)" (List.length all_messages)) 165 else 166 (true, None, Printf.sprintf "Got %d error(s) instead of warning" (List.length errors)) 167 | Some exp -> 168 let (warn_matched, warn_quality) = find_best_match ~expected_str:exp ~actual_msgs:all_msgs in 169 if warn_matched then 170 (true, Some warn_quality, 171 Printf.sprintf "Got %d warning/info message(s), match: %s" (List.length all_messages) 172 (Expected_message.match_quality_to_string warn_quality)) 173 else begin 174 let (err_matched, err_quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in 175 if err_matched then 176 (* Accept error if message matches (severity might differ) *) 177 (true, Some err_quality, 178 Printf.sprintf "Got error instead of warning, match: %s" 179 (Expected_message.match_quality_to_string err_quality)) 180 else 181 let best = if warn_quality < err_quality then warn_quality else err_quality in 182 (false, Some best, 183 Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got warnings: %s\n Got errors: %s" 184 (Expected_message.match_quality_to_string best) 185 exp (String.concat "\n " (if all_messages = [] then ["(none)"] else all_messages)) 186 (String.concat "\n " (if errors = [] then ["(none)"] else errors))) 187 end 188 end 189 | Unknown -> 190 (false, None, "Unknown test type") 191 in 192 { file = test; passed; actual_errors = errors; actual_warnings = warnings; 193 actual_infos = infos; expected_message = expected_msg; match_quality; details } 194 with e -> 195 { file = test; passed = false; actual_errors = []; actual_warnings = []; 196 actual_infos = []; expected_message = None; match_quality = None; 197 details = Printf.sprintf "Exception: %s" (Printexc.to_string e) } 198 199(** Group tests by category *) 200let group_by_category tests = 201 let tbl = Hashtbl.create 16 in 202 List.iter (fun test -> 203 let cat = test.file.category in 204 let existing = try Hashtbl.find tbl cat with Not_found -> [] in 205 Hashtbl.replace tbl cat (test :: existing) 206 ) tests; 207 Hashtbl.fold (fun k v acc -> (k, List.rev v) :: acc) tbl [] 208 |> List.sort (fun (a, _) (b, _) -> String.compare a b) 209 210(** Print summary to console *) 211let print_summary results = 212 let by_category = group_by_category results in 213 Printf.printf "\n=== Results by Category ===\n"; 214 List.iter (fun (cat, tests) -> 215 let passed = List.filter (fun r -> r.passed) tests |> List.length in 216 let total = List.length tests in 217 Printf.printf "%s: %d/%d passed (%.1f%%)\n" cat passed total 218 (100.0 *. float_of_int passed /. float_of_int (max 1 total)) 219 ) by_category; 220 221 (* Breakdown by test type *) 222 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in 223 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in 224 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in 225 226 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in 227 228 Printf.printf "\n=== Results by Test Type ===\n"; 229 Printf.printf "isvalid (no errors expected): %d/%d passed (%.1f%%)\n" 230 (count_passed isvalid_results) (List.length isvalid_results) 231 (100.0 *. float_of_int (count_passed isvalid_results) /. float_of_int (max 1 (List.length isvalid_results))); 232 Printf.printf "novalid (errors expected): %d/%d passed (%.1f%%)\n" 233 (count_passed novalid_results) (List.length novalid_results) 234 (100.0 *. float_of_int (count_passed novalid_results) /. float_of_int (max 1 (List.length novalid_results))); 235 Printf.printf "haswarn (warnings expected): %d/%d passed (%.1f%%)\n" 236 (count_passed haswarn_results) (List.length haswarn_results) 237 (100.0 *. float_of_int (count_passed haswarn_results) /. float_of_int (max 1 (List.length haswarn_results))); 238 239 let total_passed = List.filter (fun r -> r.passed) results |> List.length in 240 let total = List.length results in 241 Printf.printf "\n=== Overall ===\n"; 242 Printf.printf "Total: %d/%d passed (%.1f%%)\n" total_passed total 243 (100.0 *. float_of_int total_passed /. float_of_int (max 1 total)); 244 245 (* Match quality breakdown *) 246 let count_quality q = List.filter (fun r -> 247 match r.match_quality with Some mq -> mq = q | None -> false 248 ) results |> List.length in 249 let exact = count_quality Expected_message.Exact_match in 250 let code_match = count_quality Expected_message.Code_match in 251 let msg_match = count_quality Expected_message.Message_match in 252 let substring = count_quality Expected_message.Substring_match in 253 let sev_mismatch = count_quality Expected_message.Severity_mismatch in 254 let no_match = count_quality Expected_message.No_match in 255 let no_quality = List.filter (fun r -> r.match_quality = None) results |> List.length in 256 257 Printf.printf "\n=== Match Quality ===\n"; 258 let mode_name = 259 if !strictness = Expected_message.strict then "STRICT (full)" 260 else if !strictness = Expected_message.exact_message then "STRICT (exact message)" 261 else "lenient" 262 in 263 Printf.printf "Mode: %s\n" mode_name; 264 Printf.printf "Exact matches: %d\n" exact; 265 Printf.printf "Code matches: %d\n" code_match; 266 Printf.printf "Message matches: %d\n" msg_match; 267 Printf.printf "Substring matches: %d\n" substring; 268 Printf.printf "Severity mismatches: %d\n" sev_mismatch; 269 Printf.printf "No matches: %d\n" no_match; 270 Printf.printf "N/A (isvalid or no expected): %d\n" no_quality 271 272(** Read HTML source file for display in report *) 273let read_html_source path = 274 try 275 let ic = open_in path in 276 let content = really_input_string ic (in_channel_length ic) in 277 close_in ic; 278 Some content 279 with _ -> None 280 281(** Generate HTML report *) 282let generate_html_report results output_path = 283 let by_category = group_by_category results in 284 285 let file_results = List.map (fun (category, tests) -> 286 let passed_count = List.filter (fun r -> r.passed) tests |> List.length in 287 let failed_count = List.length tests - passed_count in 288 let test_results = List.mapi (fun i r -> 289 let outcome_str = match r.file.expected with 290 | Valid -> "isvalid" 291 | Invalid -> "novalid" 292 | HasWarning -> "haswarn" 293 | Unknown -> "unknown" 294 in 295 let description = Printf.sprintf "[%s] %s" outcome_str (Filename.basename r.file.relative_path) in 296 let expected = match r.expected_message with 297 | Some m -> m 298 | None -> match r.file.expected with 299 | Valid -> "(should produce no errors or warnings)" 300 | Invalid -> "(should produce at least one error)" 301 | HasWarning -> "(should produce at least one warning)" 302 | Unknown -> "(unknown test type)" 303 in 304 let actual_str = 305 let errors = if r.actual_errors = [] then "" 306 else "Errors:\n" ^ String.concat "\n" r.actual_errors in 307 let warnings = if r.actual_warnings = [] then "" 308 else "Warnings:\n" ^ String.concat "\n" r.actual_warnings in 309 let infos = if r.actual_infos = [] then "" 310 else "Info:\n" ^ String.concat "\n" r.actual_infos in 311 if errors = "" && warnings = "" && infos = "" then "(no messages produced)" 312 else String.trim (errors ^ (if errors <> "" && warnings <> "" then "\n\n" else "") ^ 313 warnings ^ (if (errors <> "" || warnings <> "") && infos <> "" then "\n\n" else "") ^ 314 infos) 315 in 316 let match_quality_str = match r.match_quality with 317 | Some q -> Expected_message.match_quality_to_string q 318 | None -> "N/A" 319 in 320 Report.{ 321 test_num = i + 1; 322 description; 323 input = r.file.relative_path; 324 expected; 325 actual = actual_str; 326 success = r.passed; 327 details = [ 328 ("Result", r.details); 329 ("Match Quality", match_quality_str); 330 ]; 331 raw_test_data = read_html_source r.file.path; 332 } 333 ) tests in 334 Report.{ 335 filename = category; 336 test_type = "HTML5 Validator"; 337 passed_count; 338 failed_count; 339 tests = test_results; 340 } 341 ) by_category in 342 343 let total_passed = List.filter (fun r -> r.passed) results |> List.length in 344 let total_failed = List.length results - total_passed in 345 346 (* Compute match quality stats *) 347 let count_quality q = List.filter (fun r -> 348 match r.match_quality with Some mq -> mq = q | None -> false 349 ) results |> List.length in 350 let match_quality_stats : Report.match_quality_stats = { 351 exact_matches = count_quality Expected_message.Exact_match; 352 code_matches = count_quality Expected_message.Code_match; 353 message_matches = count_quality Expected_message.Message_match; 354 substring_matches = count_quality Expected_message.Substring_match; 355 severity_mismatches = count_quality Expected_message.Severity_mismatch; 356 no_matches = count_quality Expected_message.No_match; 357 not_applicable = List.filter (fun r -> r.match_quality = None) results |> List.length; 358 } in 359 360 (* Compute test type stats *) 361 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in 362 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in 363 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in 364 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in 365 let test_type_stats : Report.test_type_stats = { 366 isvalid_passed = count_passed isvalid_results; 367 isvalid_total = List.length isvalid_results; 368 novalid_passed = count_passed novalid_results; 369 novalid_total = List.length novalid_results; 370 haswarn_passed = count_passed haswarn_results; 371 haswarn_total = List.length haswarn_results; 372 } in 373 374 let mode_name = 375 if !strictness = Expected_message.strict then "STRICT (full)" 376 else if !strictness = Expected_message.exact_message then "STRICT (exact message)" 377 else "lenient" 378 in 379 380 (* Get current timestamp *) 381 let now = Unix.gettimeofday () in 382 let tm = Unix.localtime now in 383 let timestamp = Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" 384 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday 385 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec in 386 387 let report : Report.report = { 388 title = "Nu HTML Validator Tests"; 389 test_type = "validator"; 390 description = "Tests from the Nu HTML Validator (W3C's official HTML checker). \ 391 Tests validate HTML5 conformance including element nesting, required attributes, \ 392 ARIA roles, obsolete elements, and more. Each test file is classified by suffix: \ 393 -isvalid.html (should produce no errors), -novalid.html (should produce errors), \ 394 -haswarn.html (should produce warnings)."; 395 files = file_results; 396 total_passed; 397 total_failed; 398 match_quality = Some match_quality_stats; 399 test_type_breakdown = Some test_type_stats; 400 strictness_mode = Some mode_name; 401 run_timestamp = Some timestamp; 402 } in 403 Report.generate_report report output_path 404 405(** Run tests with a given strictness and return results *) 406let run_all_tests ~mode_name ~strictness_setting messages tests = 407 strictness := strictness_setting; 408 Printf.printf "\n=== Running in %s mode ===\n%!" mode_name; 409 let total = List.length tests in 410 let results = List.mapi (fun i test -> 411 Printf.printf "\r[%d/%d] %s%!" (i + 1) total test.relative_path; 412 run_test messages test 413 ) tests in 414 Printf.printf "\n%!"; 415 results 416 417(** Print failures for a test run *) 418let print_failures mode_name results = 419 Printf.printf "\n--- %s mode results ---\n" mode_name; 420 421 let failing_isvalid = List.filter (fun r -> 422 r.file.expected = Valid && not r.passed 423 ) results in 424 if failing_isvalid <> [] then begin 425 Printf.printf "Failing isvalid tests:\n"; 426 List.iter (fun r -> 427 Printf.printf " %s: %s\n" r.file.relative_path r.details 428 ) failing_isvalid 429 end; 430 431 let failing_haswarn = List.filter (fun r -> 432 r.file.expected = HasWarning && not r.passed 433 ) results in 434 if failing_haswarn <> [] then begin 435 Printf.printf "Failing haswarn tests:\n"; 436 List.iter (fun r -> 437 Printf.printf " %s\n" r.file.relative_path 438 ) failing_haswarn 439 end; 440 441 let failing_novalid = List.filter (fun r -> 442 r.file.expected = Invalid && not r.passed 443 ) results in 444 if failing_novalid <> [] then begin 445 Printf.printf "Failing novalid tests (first 20):\n"; 446 List.iteri (fun i r -> 447 if i < 20 then Printf.printf " %s\n" r.file.relative_path 448 ) failing_novalid 449 end; 450 451 let passed = List.filter (fun r -> r.passed) results |> List.length in 452 let total = List.length results in 453 Printf.printf "%s: %d/%d passed (%.1f%%)\n%!" mode_name passed total 454 (100.0 *. float_of_int passed /. float_of_int total) 455 456(** Generate combined HTML report for both modes *) 457let generate_combined_html_report ~lenient_results ~strict_results output_path = 458 (* Helper to build file results from a set of results *) 459 let build_file_results results = 460 let by_category = group_by_category results in 461 List.map (fun (category, tests) -> 462 let passed_count = List.filter (fun r -> r.passed) tests |> List.length in 463 let failed_count = List.length tests - passed_count in 464 let test_results = List.mapi (fun i r -> 465 let outcome_str = match r.file.expected with 466 | Valid -> "isvalid" 467 | Invalid -> "novalid" 468 | HasWarning -> "haswarn" 469 | Unknown -> "unknown" 470 in 471 let description = Printf.sprintf "[%s] %s" outcome_str (Filename.basename r.file.relative_path) in 472 let expected = match r.expected_message with 473 | Some m -> m 474 | None -> match r.file.expected with 475 | Valid -> "(should produce no errors or warnings)" 476 | Invalid -> "(should produce at least one error)" 477 | HasWarning -> "(should produce at least one warning)" 478 | Unknown -> "(unknown test type)" 479 in 480 let actual_str = 481 let errors = if r.actual_errors = [] then "" 482 else "Errors:\n" ^ String.concat "\n" r.actual_errors in 483 let warnings = if r.actual_warnings = [] then "" 484 else "Warnings:\n" ^ String.concat "\n" r.actual_warnings in 485 let infos = if r.actual_infos = [] then "" 486 else "Info:\n" ^ String.concat "\n" r.actual_infos in 487 if errors = "" && warnings = "" && infos = "" then "(no messages produced)" 488 else String.trim (errors ^ (if errors <> "" && warnings <> "" then "\n\n" else "") ^ 489 warnings ^ (if (errors <> "" || warnings <> "") && infos <> "" then "\n\n" else "") ^ 490 infos) 491 in 492 let match_quality_str = match r.match_quality with 493 | Some q -> Expected_message.match_quality_to_string q 494 | None -> "N/A" 495 in 496 Report.{ 497 test_num = i + 1; 498 description; 499 input = r.file.relative_path; 500 expected; 501 actual = actual_str; 502 success = r.passed; 503 details = [ 504 ("Result", r.details); 505 ("Match Quality", match_quality_str); 506 ]; 507 raw_test_data = read_html_source r.file.path; 508 } 509 ) tests in 510 Report.{ 511 filename = category; 512 test_type = "HTML5 Validator"; 513 passed_count; 514 failed_count; 515 tests = test_results; 516 } 517 ) by_category 518 in 519 520 let compute_stats results mode_name = 521 let total_passed = List.filter (fun r -> r.passed) results |> List.length in 522 let total_failed = List.length results - total_passed in 523 let count_quality q = List.filter (fun r -> 524 match r.match_quality with Some mq -> mq = q | None -> false 525 ) results |> List.length in 526 let match_quality_stats : Report.match_quality_stats = { 527 exact_matches = count_quality Expected_message.Exact_match; 528 code_matches = count_quality Expected_message.Code_match; 529 message_matches = count_quality Expected_message.Message_match; 530 substring_matches = count_quality Expected_message.Substring_match; 531 severity_mismatches = count_quality Expected_message.Severity_mismatch; 532 no_matches = count_quality Expected_message.No_match; 533 not_applicable = List.filter (fun r -> r.match_quality = None) results |> List.length; 534 } in 535 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in 536 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in 537 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in 538 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in 539 let test_type_stats : Report.test_type_stats = { 540 isvalid_passed = count_passed isvalid_results; 541 isvalid_total = List.length isvalid_results; 542 novalid_passed = count_passed novalid_results; 543 novalid_total = List.length novalid_results; 544 haswarn_passed = count_passed haswarn_results; 545 haswarn_total = List.length haswarn_results; 546 } in 547 (total_passed, total_failed, match_quality_stats, test_type_stats, mode_name) 548 in 549 550 let lenient_stats = compute_stats lenient_results "lenient" in 551 let strict_stats = compute_stats strict_results "strict" in 552 553 (* Use strict results for the main report, but include both in description *) 554 let (strict_passed, strict_failed, strict_mq, strict_tt, _) = strict_stats in 555 let (lenient_passed, _lenient_failed, _, _, _) = lenient_stats in 556 557 let now = Unix.gettimeofday () in 558 let tm = Unix.localtime now in 559 let timestamp = Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" 560 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday 561 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec in 562 563 let total = List.length strict_results in 564 let description = Printf.sprintf 565 "Tests from the Nu HTML Validator (W3C's official HTML checker). \ 566 Tests validate HTML5 conformance including element nesting, required attributes, \ 567 ARIA roles, obsolete elements, and more.\n\n\ 568 LENIENT mode: %d/%d passed (%.1f%%) - allows substring matching\n\ 569 STRICT mode: %d/%d passed (%.1f%%) - requires exact message matching" 570 lenient_passed total (100.0 *. float_of_int lenient_passed /. float_of_int total) 571 strict_passed total (100.0 *. float_of_int strict_passed /. float_of_int total) 572 in 573 574 let report : Report.report = { 575 title = "Nu HTML Validator Tests (Lenient + Strict)"; 576 test_type = "validator"; 577 description; 578 files = build_file_results strict_results; (* Show strict results in detail *) 579 total_passed = strict_passed; 580 total_failed = strict_failed; 581 match_quality = Some strict_mq; 582 test_type_breakdown = Some strict_tt; 583 strictness_mode = Some (Printf.sprintf "BOTH (Lenient: %d/%d, Strict: %d/%d)" 584 lenient_passed total strict_passed total); 585 run_timestamp = Some timestamp; 586 } in 587 Report.generate_report report output_path 588 589let () = 590 (* Parse command line arguments *) 591 let args = Array.to_list Sys.argv |> List.tl in 592 let is_strict = List.mem "--strict" args in 593 let is_both = List.mem "--both" args in 594 let non_flag_args = List.filter (fun s -> not (String.length s > 0 && s.[0] = '-')) args in 595 let tests_dir = match non_flag_args with x :: _ -> x | [] -> "validator/tests" in 596 let report_path = match non_flag_args with _ :: x :: _ -> x | _ -> "test_validator_report.html" in 597 598 Printf.printf "Loading messages.json...\n%!"; 599 let messages_path = Filename.concat tests_dir "messages.json" in 600 let messages = Validator_messages.load messages_path in 601 Printf.printf "Loaded %d expected messages\n%!" (Validator_messages.count messages); 602 603 Printf.printf "Discovering test files...\n%!"; 604 let tests = discover_tests tests_dir in 605 Printf.printf "Found %d test files\n%!" (List.length tests); 606 607 if is_both then begin 608 (* Run both modes *) 609 let lenient_results = run_all_tests ~mode_name:"LENIENT" 610 ~strictness_setting:Expected_message.lenient messages tests in 611 let strict_results = run_all_tests ~mode_name:"STRICT" 612 ~strictness_setting:Expected_message.exact_message messages tests in 613 614 print_failures "LENIENT" lenient_results; 615 print_failures "STRICT" strict_results; 616 617 Printf.printf "\n=== Summary ===\n"; 618 let lenient_passed = List.filter (fun r -> r.passed) lenient_results |> List.length in 619 let strict_passed = List.filter (fun r -> r.passed) strict_results |> List.length in 620 let total = List.length tests in 621 Printf.printf "LENIENT: %d/%d (%.1f%%)\n" lenient_passed total 622 (100.0 *. float_of_int lenient_passed /. float_of_int total); 623 Printf.printf "STRICT: %d/%d (%.1f%%)\n" strict_passed total 624 (100.0 *. float_of_int strict_passed /. float_of_int total); 625 626 generate_combined_html_report ~lenient_results ~strict_results report_path; 627 628 (* Exit with error if strict mode has failures *) 629 let strict_failed = List.filter (fun r -> not r.passed) strict_results |> List.length in 630 exit (if strict_failed > 0 then 1 else 0) 631 end else begin 632 (* Single mode (original behavior) *) 633 if is_strict then begin 634 strictness := Expected_message.exact_message; 635 Printf.printf "Running in STRICT mode (exact message matching required)\n%!" 636 end; 637 638 Printf.printf "Running tests...\n%!"; 639 let total = List.length tests in 640 let results = List.mapi (fun i test -> 641 Printf.printf "\r[%d/%d] %s%!" (i + 1) total test.relative_path; 642 run_test messages test 643 ) tests in 644 Printf.printf "\n%!"; 645 646 (* Print failing isvalid tests *) 647 let failing_isvalid = List.filter (fun r -> 648 r.file.expected = Valid && not r.passed 649 ) results in 650 if failing_isvalid <> [] then begin 651 Printf.printf "\n=== Failing isvalid tests ===\n"; 652 List.iter (fun r -> 653 Printf.printf "%s: %s\n" r.file.relative_path r.details 654 ) failing_isvalid 655 end; 656 657 (* Print failing haswarn tests *) 658 let failing_haswarn = List.filter (fun r -> 659 r.file.expected = HasWarning && not r.passed 660 ) results in 661 if failing_haswarn <> [] then begin 662 Printf.printf "\n=== Failing haswarn tests ===\n"; 663 List.iter (fun r -> 664 Printf.printf "%s\n" r.file.relative_path 665 ) failing_haswarn 666 end; 667 668 (* Print failing novalid tests *) 669 let failing_novalid = List.filter (fun r -> 670 r.file.expected = Invalid && not r.passed 671 ) results in 672 if failing_novalid <> [] then begin 673 Printf.printf "\n=== Failing novalid tests (first 50) ===\n"; 674 List.iteri (fun i r -> 675 if i < 50 then Printf.printf "%s\n" r.file.relative_path 676 ) failing_novalid 677 end; 678 679 print_summary results; 680 generate_html_report results report_path; 681 682 let failed_count = List.filter (fun r -> not r.passed) results |> List.length in 683 exit (if failed_count > 0 then 1 else 0) 684 end