+27
-1
lib/html5rw/parser/parser_constants.ml
+27
-1
lib/html5rw/parser/parser_constants.ml
···
3
3
(* Use Astring for string operations *)
4
4
let lowercase = Astring.String.Ascii.lowercase
5
5
6
+
(* Helper to create a hashtable set from a list for O(1) membership *)
7
+
let make_set elements =
8
+
let tbl = Hashtbl.create (List.length elements) in
9
+
List.iter (fun e -> Hashtbl.add tbl e ()) elements;
10
+
tbl
11
+
6
12
(* Void elements - no end tag allowed *)
7
13
let void_elements = [
8
14
"area"; "base"; "br"; "col"; "embed"; "hr"; "img"; "input";
9
15
"link"; "meta"; "source"; "track"; "wbr"
10
16
]
17
+
let void_elements_tbl = make_set void_elements
11
18
12
19
(* Raw text elements - content is raw text *)
13
20
let raw_text_elements = ["script"; "style"]
···
20
27
"a"; "b"; "big"; "code"; "em"; "font"; "i"; "nobr"; "s"; "small";
21
28
"strike"; "strong"; "tt"; "u"
22
29
]
30
+
let formatting_elements_tbl = make_set formatting_elements
23
31
24
32
(* Special elements *)
25
33
let special_elements = [
···
35
43
"tbody"; "td"; "template"; "textarea"; "tfoot"; "th"; "thead"; "title";
36
44
"tr"; "track"; "ul"; "wbr"; "xmp"
37
45
]
46
+
let special_elements_tbl = make_set special_elements
38
47
39
48
(* Heading elements *)
40
49
let heading_elements = ["h1"; "h2"; "h3"; "h4"; "h5"; "h6"]
50
+
let heading_elements_tbl = make_set heading_elements
41
51
42
52
(* Implied end tag elements *)
43
53
let implied_end_tags = [
44
54
"dd"; "dt"; "li"; "optgroup"; "option"; "p"; "rb"; "rp"; "rt"; "rtc"
45
55
]
56
+
let implied_end_tags_tbl = make_set implied_end_tags
46
57
47
58
(* Thoroughly implied end tags *)
48
59
let thoroughly_implied_end_tags = [
49
60
"caption"; "colgroup"; "dd"; "dt"; "li"; "optgroup"; "option"; "p";
50
61
"rb"; "rp"; "rt"; "rtc"; "tbody"; "td"; "tfoot"; "th"; "thead"; "tr"
51
62
]
63
+
let thoroughly_implied_end_tags_tbl = make_set thoroughly_implied_end_tags
52
64
53
65
(* Scope elements for various scope checks *)
54
66
let default_scope = [
···
62
74
let table_scope = ["html"; "table"; "template"]
63
75
64
76
let select_scope_exclude = ["optgroup"; "option"]
77
+
let select_scope_exclude_tbl = make_set select_scope_exclude
65
78
66
79
(* MathML text integration points *)
67
80
let mathml_text_integration = ["mi"; "mo"; "mn"; "ms"; "mtext"]
81
+
let mathml_text_integration_tbl = make_set mathml_text_integration
68
82
69
83
(* MathML attribute adjustments *)
70
84
let mathml_attr_adjustments = [
···
80
94
81
95
(* SVG HTML integration points *)
82
96
let svg_html_integration = ["foreignObject"; "desc"; "title"]
97
+
let svg_html_integration_tbl = make_set (List.map lowercase svg_html_integration)
83
98
84
99
(* SVG tag name adjustments *)
85
100
let svg_tag_adjustments = [
···
278
293
"http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd"
279
294
]
280
295
281
-
(* Helper functions *)
296
+
(* Helper functions - O(1) hashtable lookups *)
297
+
let is_void_element name = Hashtbl.mem void_elements_tbl name
298
+
let is_formatting_element name = Hashtbl.mem formatting_elements_tbl name
299
+
let is_special_element name = Hashtbl.mem special_elements_tbl name
300
+
let is_heading_element name = Hashtbl.mem heading_elements_tbl name
301
+
let is_implied_end_tag name = Hashtbl.mem implied_end_tags_tbl name
302
+
let is_thoroughly_implied_end_tag name = Hashtbl.mem thoroughly_implied_end_tags_tbl name
303
+
let is_mathml_text_integration name = Hashtbl.mem mathml_text_integration_tbl name
304
+
let is_svg_html_integration name = Hashtbl.mem svg_html_integration_tbl (lowercase name)
305
+
let is_select_scope_exclude name = Hashtbl.mem select_scope_exclude_tbl name
306
+
307
+
(* Backwards compatibility aliases *)
282
308
let is_void = List.mem
283
309
let is_formatting = List.mem
284
310
let is_special name = List.mem name special_elements
+14
-14
lib/html5rw/parser/parser_tree_builder.ml
+14
-14
lib/html5rw/parser/parser_tree_builder.ml
···
294
294
let is_html_integration_point node =
295
295
(* SVG foreignObject, desc, and title are always HTML integration points *)
296
296
if node.Dom.namespace = Some "svg" &&
297
-
List.mem node.Dom.name Parser_constants.svg_html_integration then true
297
+
Parser_constants.is_svg_html_integration node.Dom.name then true
298
298
(* annotation-xml is an HTML integration point only with specific encoding values *)
299
299
else if node.Dom.namespace = Some "mathml" && node.Dom.name = "annotation-xml" then
300
300
match List.assoc_opt "encoding" node.Dom.attrs with
···
307
307
(* Check if element is a MathML text integration point *)
308
308
let is_mathml_text_integration_point node =
309
309
node.Dom.namespace = Some "mathml" &&
310
-
List.mem node.Dom.name ["mi"; "mo"; "mn"; "ms"; "mtext"]
310
+
Parser_constants.is_mathml_text_integration node.Dom.name
311
311
312
312
(* Scope checks - integration points also terminate scope (except for table scope) *)
313
313
(* Per WHATWG spec, scope checks only consider HTML namespace elements for the target names *)
···
341
341
| [] -> false
342
342
| n :: rest ->
343
343
if n.Dom.name = name then true
344
-
else if not (List.mem n.Dom.name Parser_constants.select_scope_exclude) then false
344
+
else if not (Parser_constants.is_select_scope_exclude n.Dom.name) then false
345
345
else check rest
346
346
in
347
347
check t.open_elements
···
350
350
let generate_implied_end_tags t ?except () =
351
351
let rec loop () =
352
352
match current_node t with
353
-
| Some n when List.mem n.Dom.name Parser_constants.implied_end_tags ->
353
+
| Some n when Parser_constants.is_implied_end_tag n.Dom.name ->
354
354
(match except with
355
355
| Some ex when n.Dom.name = ex -> ()
356
356
| _ -> pop_current t; loop ())
···
361
361
let generate_all_implied_end_tags t =
362
362
let rec loop () =
363
363
match current_node t with
364
-
| Some n when List.mem n.Dom.name Parser_constants.thoroughly_implied_end_tags ->
364
+
| Some n when Parser_constants.is_thoroughly_implied_end_tag n.Dom.name ->
365
365
pop_current t; loop ()
366
366
| _ -> ()
367
367
in
···
1105
1105
when List.mem name ["address"; "article"; "aside"; "blockquote"; "center"; "details"; "dialog"; "dir"; "div"; "dl"; "fieldset"; "figcaption"; "figure"; "footer"; "header"; "hgroup"; "main"; "menu"; "nav"; "ol"; "p"; "search"; "section"; "summary"; "ul"] ->
1106
1106
if has_element_in_button_scope t "p" then close_p_element t;
1107
1107
ignore (insert_element t name ~push:true attrs)
1108
-
| Token.Tag { kind = Token.Start; name; attrs; _ } when List.mem name Parser_constants.heading_elements ->
1108
+
| Token.Tag { kind = Token.Start; name; attrs; _ } when Parser_constants.is_heading_element name ->
1109
1109
if has_element_in_button_scope t "p" then close_p_element t;
1110
1110
(match current_node t with
1111
-
| Some n when List.mem n.Dom.name Parser_constants.heading_elements ->
1111
+
| Some n when Parser_constants.is_heading_element n.Dom.name ->
1112
1112
parse_error t "unexpected-start-tag";
1113
1113
pop_current t
1114
1114
| _ -> ());
···
1243
1243
| _ -> ());
1244
1244
pop_until_tag t name
1245
1245
end
1246
-
| Token.Tag { kind = Token.End; name; _ } when List.mem name Parser_constants.heading_elements ->
1246
+
| Token.Tag { kind = Token.End; name; _ } when Parser_constants.is_heading_element name ->
1247
1247
if not (has_element_in_scope_impl t Parser_constants.heading_elements Parser_constants.default_scope ~check_integration_points:true) then
1248
1248
parse_error t "unexpected-end-tag"
1249
1249
else begin
···
1437
1437
reconstruct_active_formatting t;
1438
1438
ignore (insert_element t name ~push:true attrs);
1439
1439
(* Check for self-closing on non-void HTML element *)
1440
-
if self_closing && not (List.mem name Parser_constants.void_elements) then
1440
+
if self_closing && not (Parser_constants.is_void_element name) then
1441
1441
parse_error t "non-void-html-element-start-tag-with-trailing-solidus"
1442
1442
| Token.Tag { kind = Token.End; name; _ } ->
1443
1443
(* Any other end tag *)
···
1943
1943
ignore (insert_element t name attrs)
1944
1944
(* Don't push to stack - void elements *)
1945
1945
(* Handle formatting elements in select *)
1946
-
| Token.Tag { kind = Token.Start; name; attrs; _ } when List.mem name Parser_constants.formatting_elements ->
1946
+
| Token.Tag { kind = Token.Start; name; attrs; _ } when Parser_constants.is_formatting_element name ->
1947
1947
reconstruct_active_formatting t;
1948
1948
let node = insert_element t name ~push:true attrs in
1949
1949
push_formatting_element t node name attrs
1950
-
| Token.Tag { kind = Token.End; name; _ } when List.mem name Parser_constants.formatting_elements ->
1950
+
| Token.Tag { kind = Token.End; name; _ } when Parser_constants.is_formatting_element name ->
1951
1951
(* Find select element and check if formatting element is inside select *)
1952
1952
let select_idx = ref None in
1953
1953
let fmt_idx = ref None in
···
2211
2211
let is_html_integration_point node =
2212
2212
(* SVG foreignObject, desc, and title are always HTML integration points *)
2213
2213
if node.Dom.namespace = Some "svg" &&
2214
-
List.mem node.Dom.name Parser_constants.svg_html_integration then true
2214
+
Parser_constants.is_svg_html_integration node.Dom.name then true
2215
2215
(* annotation-xml is an HTML integration point only with specific encoding values *)
2216
2216
else if node.Dom.namespace = Some "mathml" && node.Dom.name = "annotation-xml" then
2217
2217
match List.assoc_opt "encoding" node.Dom.attrs with
···
2224
2224
(* Check for MathML text integration points *)
2225
2225
let is_mathml_text_integration_point node =
2226
2226
node.Dom.namespace = Some "mathml" &&
2227
-
List.mem node.Dom.name ["mi"; "mo"; "mn"; "ms"; "mtext"]
2227
+
Parser_constants.is_mathml_text_integration node.Dom.name
2228
2228
in
2229
2229
(* Foreign content handling *)
2230
2230
let in_foreign =
···
2293
2293
let is_html_integration_point node =
2294
2294
(* SVG foreignObject, desc, and title are always HTML integration points *)
2295
2295
if node.Dom.namespace = Some "svg" &&
2296
-
List.mem node.Dom.name Parser_constants.svg_html_integration then true
2296
+
Parser_constants.is_svg_html_integration node.Dom.name then true
2297
2297
(* annotation-xml is an HTML integration point only with specific encoding values *)
2298
2298
else if node.Dom.namespace = Some "mathml" && node.Dom.name = "annotation-xml" then
2299
2299
match List.assoc_opt "encoding" node.Dom.attrs with
+23
lib/htmlrw_check/datatype/datatype.ml
+23
lib/htmlrw_check/datatype/datatype.ml
···
41
41
if start > end_pos then ""
42
42
else String.sub s start (end_pos - start + 1)
43
43
44
+
(** Split string on HTML whitespace characters (space, tab, LF, FF, CR).
45
+
Filters out empty tokens. Used for space-separated attribute values. *)
46
+
let split_on_whitespace s =
47
+
let len = String.length s in
48
+
let rec split acc start i =
49
+
if i >= len then
50
+
if i > start then
51
+
List.rev ((String.sub s start (i - start)) :: acc)
52
+
else
53
+
List.rev acc
54
+
else if is_whitespace s.[i] then
55
+
let acc' =
56
+
if i > start then
57
+
(String.sub s start (i - start)) :: acc
58
+
else
59
+
acc
60
+
in
61
+
split acc' (i + 1) (i + 1)
62
+
else
63
+
split acc start (i + 1)
64
+
in
65
+
split [] 0 0
66
+
44
67
(** Factory for creating enum-based validators.
45
68
Many HTML attributes accept a fixed set of keyword values.
46
69
Uses Hashtbl for O(1) membership check. *)
+4
lib/htmlrw_check/datatype/datatype.mli
+4
lib/htmlrw_check/datatype/datatype.mli
···
44
44
(** Trim HTML5 whitespace from both ends of a string. *)
45
45
val trim_html_spaces : string -> string
46
46
47
+
(** Split string on HTML5 whitespace characters (space, tab, LF, FF, CR).
48
+
Filters out empty tokens. Used for space-separated attribute values. *)
49
+
val split_on_whitespace : string -> string list
50
+
47
51
(** {2 Datatype Factories} *)
48
52
49
53
(** Create an enum-based validator for attributes with fixed keyword values.
+6
-20
lib/htmlrw_check/datatype/dt_autocomplete.ml
+6
-20
lib/htmlrw_check/datatype/dt_autocomplete.ml
···
1
1
(** Autocomplete attribute validation based on HTML5 spec *)
2
2
3
-
(** Check if character is whitespace *)
4
-
let is_whitespace c = c = ' ' || c = '\t' || c = '\n' || c = '\r'
3
+
(* Use shared utilities from Datatype *)
4
+
let is_whitespace = Datatype.is_whitespace
5
+
let to_ascii_lowercase = Datatype.to_ascii_lowercase
5
6
6
-
(** Convert character to ASCII lowercase *)
7
-
let to_ascii_lowercase c =
8
-
if c >= 'A' && c <= 'Z' then Char.chr (Char.code c + 32) else c
9
-
10
-
(** Trim whitespace from string *)
7
+
(** Trim whitespace from string and collapse internal whitespace *)
11
8
let trim_whitespace s =
12
9
let s = String.trim s in
13
10
(* Also collapse internal whitespace *)
···
104
101
"impp";
105
102
]
106
103
107
-
(** Split string on whitespace *)
108
-
let split_on_whitespace s =
109
-
let rec split acc start i =
110
-
if i >= String.length s then
111
-
if start < i then List.rev (String.sub s start (i - start) :: acc)
112
-
else List.rev acc
113
-
else if is_whitespace s.[i] then
114
-
if start < i then
115
-
split (String.sub s start (i - start) :: acc) (i + 1) (i + 1)
116
-
else split acc (i + 1) (i + 1)
117
-
else split acc start (i + 1)
118
-
in
119
-
split [] 0 0
104
+
(** Split string on whitespace - uses shared utility *)
105
+
let split_on_whitespace = Datatype.split_on_whitespace
120
106
121
107
(** Check if string starts with prefix *)
122
108
let starts_with s prefix =
+34
-44
lib/htmlrw_check/semantic/id_checker.ml
+34
-44
lib/htmlrw_check/semantic/id_checker.ml
···
50
50
else
51
51
None
52
52
53
-
(** Split whitespace-separated ID references. *)
54
-
let split_ids value =
55
-
let rec split acc start i =
56
-
if i >= String.length value then
57
-
if i > start then
58
-
(String.sub value start (i - start)) :: acc
59
-
else
60
-
acc
61
-
else
62
-
match value.[i] with
63
-
| ' ' | '\t' | '\n' | '\r' ->
64
-
let acc' =
65
-
if i > start then
66
-
(String.sub value start (i - start)) :: acc
67
-
else
68
-
acc
69
-
in
70
-
split acc' (i + 1) (i + 1)
71
-
| _ ->
72
-
split acc start (i + 1)
73
-
in
74
-
List.rev (split [] 0 0)
53
+
(** Split whitespace-separated ID references - uses shared utility. *)
54
+
let split_ids = Datatype.split_on_whitespace
75
55
76
-
(** Attributes that reference a single ID. *)
77
-
let single_id_ref_attrs = [
78
-
"for"; (* label *)
79
-
"form"; (* form-associated elements *)
80
-
"list"; (* input *)
81
-
"aria-activedescendant";
82
-
"popovertarget"; (* button - references popover element *)
83
-
"commandfor"; (* button - references element to control *)
84
-
"anchor"; (* popover - references anchor element *)
85
-
]
56
+
(** Attributes that reference a single ID - O(1) lookup. *)
57
+
let single_id_ref_attrs =
58
+
let tbl = Hashtbl.create 8 in
59
+
List.iter (fun a -> Hashtbl.add tbl a ()) [
60
+
"for"; (* label *)
61
+
"form"; (* form-associated elements *)
62
+
"list"; (* input *)
63
+
"aria-activedescendant";
64
+
"popovertarget"; (* button - references popover element *)
65
+
"commandfor"; (* button - references element to control *)
66
+
"anchor"; (* popover - references anchor element *)
67
+
];
68
+
tbl
86
69
87
-
(** Attributes that reference multiple IDs (space-separated). *)
88
-
let multi_id_ref_attrs = [
89
-
"headers"; (* td, th *)
90
-
"aria-labelledby";
91
-
"aria-describedby";
92
-
"aria-controls";
93
-
"aria-flowto";
94
-
"aria-owns";
95
-
"itemref";
96
-
]
70
+
let is_single_id_ref_attr name = Hashtbl.mem single_id_ref_attrs name
71
+
72
+
(** Attributes that reference multiple IDs (space-separated) - O(1) lookup. *)
73
+
let multi_id_ref_attrs =
74
+
let tbl = Hashtbl.create 8 in
75
+
List.iter (fun a -> Hashtbl.add tbl a ()) [
76
+
"headers"; (* td, th *)
77
+
"aria-labelledby";
78
+
"aria-describedby";
79
+
"aria-controls";
80
+
"aria-flowto";
81
+
"aria-owns";
82
+
"itemref";
83
+
];
84
+
tbl
85
+
86
+
let is_multi_id_ref_attr name = Hashtbl.mem multi_id_ref_attrs name
97
87
98
88
(** Check and store an ID attribute. *)
99
89
let check_id state ~element:_ ~id ~location:_ collector =
···
161
151
if String.length value > 0 then
162
152
Hashtbl.add state.map_names value ()
163
153
164
-
| attr when List.mem attr single_id_ref_attrs ->
154
+
| attr when is_single_id_ref_attr attr ->
165
155
add_reference state ~referring_element:element
166
156
~attribute:attr ~referenced_id:value ~location
167
157
168
-
| attr when List.mem attr multi_id_ref_attrs ->
158
+
| attr when is_multi_id_ref_attr attr ->
169
159
(* Split space-separated IDs and add each as a reference *)
170
160
let ids = split_ids value in
171
161
List.iter (fun id ->
+2
-22
lib/htmlrw_check/specialized/microdata_checker.ml
+2
-22
lib/htmlrw_check/specialized/microdata_checker.ml
···
43
43
Hashtbl.clear state.all_ids;
44
44
state.html_element_seen <- false
45
45
46
-
(** Split whitespace-separated values. *)
47
-
let split_whitespace value =
48
-
let rec split acc start i =
49
-
if i >= String.length value then
50
-
if i > start then
51
-
(String.sub value start (i - start)) :: acc
52
-
else
53
-
acc
54
-
else
55
-
match value.[i] with
56
-
| ' ' | '\t' | '\n' | '\r' ->
57
-
let acc' =
58
-
if i > start then
59
-
(String.sub value start (i - start)) :: acc
60
-
else
61
-
acc
62
-
in
63
-
split acc' (i + 1) (i + 1)
64
-
| _ ->
65
-
split acc start (i + 1)
66
-
in
67
-
List.rev (split [] 0 0)
46
+
(** Split whitespace-separated values - uses shared utility. *)
47
+
let split_whitespace = Datatype.split_on_whitespace
68
48
69
49
(** Check if a string is a valid URL (contains a colon). *)
70
50
let is_url s =
+4
test/test_all.ml
+4
test/test_all.ml
···
664
664
files = !all_files;
665
665
total_passed = !total_passed;
666
666
total_failed = !total_failed;
667
+
match_quality = None;
668
+
test_type_breakdown = None;
669
+
strictness_mode = None;
670
+
run_timestamp = None;
667
671
} in
668
672
Report.generate_report report "html5lib_test_report.html";
669
673
+4
test/test_encoding.ml
+4
test/test_encoding.ml
···
184
184
files = List.rev !file_results;
185
185
total_passed = !total_passed;
186
186
total_failed = !total_failed;
187
+
match_quality = None;
188
+
test_type_breakdown = None;
189
+
strictness_mode = None;
190
+
run_timestamp = None;
187
191
} in
188
192
Report.generate_report report "test_encoding_report.html";
189
193
+4
test/test_html5lib.ml
+4
test/test_html5lib.ml
···
229
229
files = List.rev !file_results;
230
230
total_passed = !total_passed;
231
231
total_failed = !total_failed;
232
+
match_quality = None;
233
+
test_type_breakdown = None;
234
+
strictness_mode = None;
235
+
run_timestamp = None;
232
236
} in
233
237
Report.generate_report report "test_html5lib_report.html";
234
238
+548
-157
test/test_report.ml
+548
-157
test/test_report.ml
···
1
-
(* HTML Test Report Generator *)
1
+
(* HTML Test Report Generator - Standalone HTML reports for test results *)
2
2
3
3
type test_result = {
4
4
test_num : int;
···
19
19
tests : test_result list;
20
20
}
21
21
22
+
type 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
+
32
+
type 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
+
22
41
type report = {
23
42
title : string;
24
43
test_type : string;
25
-
description : string; (* Explanation of what this test suite validates *)
44
+
description : string;
26
45
files : file_result list;
27
46
total_passed : int;
28
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;
29
52
}
30
53
31
54
let html_escape s =
···
41
64
) s;
42
65
Buffer.contents buf
43
66
44
-
(* No truncation - show full content for standalone reports *)
45
67
let truncate_string ?(max_len=10000) s =
46
68
if String.length s <= max_len then s
47
69
else String.sub s 0 max_len ^ "\n... (truncated at " ^ string_of_int max_len ^ " chars)"
···
53
75
--bg-tertiary: #0f3460;
54
76
--text-primary: #eee;
55
77
--text-secondary: #aaa;
78
+
--text-muted: #666;
56
79
--accent: #e94560;
80
+
--accent-light: #ff6b8a;
57
81
--success: #4ade80;
82
+
--success-dim: rgba(74, 222, 128, 0.2);
58
83
--failure: #f87171;
84
+
--failure-dim: rgba(248, 113, 113, 0.2);
85
+
--warning: #fbbf24;
86
+
--info: #60a5fa;
59
87
--border: #333;
88
+
--code-bg: #0d1117;
60
89
}
61
90
62
91
* { box-sizing: border-box; margin: 0; padding: 0; }
···
68
97
line-height: 1.6;
69
98
}
70
99
71
-
.container {
72
-
max-width: 1400px;
73
-
margin: 0 auto;
74
-
padding: 20px;
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);
75
109
}
76
110
77
-
header {
78
-
background: var(--bg-secondary);
79
-
padding: 20px;
80
-
border-radius: 8px;
81
-
margin-bottom: 20px;
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;
82
118
}
83
119
84
-
header h1 {
120
+
.hero h1::before {
121
+
content: "🧪";
85
122
font-size: 1.5rem;
86
-
margin-bottom: 10px;
87
-
color: var(--accent);
88
123
}
89
124
90
-
.summary {
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 {
91
133
display: flex;
92
134
gap: 20px;
93
135
flex-wrap: wrap;
136
+
font-size: 0.9rem;
137
+
color: var(--text-muted);
138
+
}
139
+
140
+
.hero-meta span {
141
+
display: flex;
94
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;
95
152
}
96
153
97
-
.stat {
98
-
padding: 8px 16px;
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);
99
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;
100
246
font-weight: 600;
101
247
}
102
248
103
-
.stat.total { background: var(--bg-tertiary); }
104
-
.stat.passed { background: rgba(74, 222, 128, 0.2); color: var(--success); }
105
-
.stat.failed { background: rgba(248, 113, 113, 0.2); color: var(--failure); }
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); }
106
253
254
+
/* Controls */
107
255
.controls {
108
256
display: flex;
109
-
gap: 10px;
110
-
margin-top: 10px;
257
+
gap: 12px;
258
+
margin-bottom: 25px;
111
259
flex-wrap: wrap;
260
+
align-items: center;
112
261
}
113
262
114
263
input[type="search"], select {
115
-
padding: 8px 12px;
264
+
padding: 10px 14px;
116
265
border: 1px solid var(--border);
117
-
border-radius: 6px;
118
-
background: var(--bg-primary);
266
+
border-radius: 8px;
267
+
background: var(--bg-secondary);
119
268
color: var(--text-primary);
120
269
font-size: 14px;
121
270
}
122
271
123
272
input[type="search"] { width: 300px; }
273
+
input[type="search"]:focus, select:focus {
274
+
outline: none;
275
+
border-color: var(--accent);
276
+
}
124
277
125
278
button {
126
-
padding: 8px 16px;
279
+
padding: 10px 18px;
127
280
border: none;
128
-
border-radius: 6px;
281
+
border-radius: 8px;
129
282
background: var(--accent);
130
283
color: white;
131
284
cursor: pointer;
132
285
font-size: 14px;
286
+
font-weight: 500;
287
+
transition: all 0.2s;
133
288
}
134
289
135
-
button:hover { opacity: 0.9; }
290
+
button:hover { background: var(--accent-light); }
291
+
button.secondary {
292
+
background: var(--bg-tertiary);
293
+
border: 1px solid var(--border);
294
+
}
295
+
button.secondary:hover { background: var(--bg-secondary); }
296
+
297
+
/* Sidebar */
298
+
.layout {
299
+
display: grid;
300
+
grid-template-columns: 280px 1fr;
301
+
gap: 30px;
302
+
}
136
303
137
304
.sidebar {
138
-
position: fixed;
139
-
left: 0;
140
-
top: 0;
141
-
bottom: 0;
142
-
width: 280px;
143
-
background: var(--bg-secondary);
144
-
border-right: 1px solid var(--border);
305
+
position: sticky;
306
+
top: 20px;
307
+
height: fit-content;
308
+
max-height: calc(100vh - 40px);
145
309
overflow-y: auto;
146
-
padding: 10px;
147
-
padding-top: 20px;
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;
148
323
}
149
324
150
325
.sidebar-item {
151
-
padding: 8px 12px;
152
-
border-radius: 6px;
326
+
padding: 10px 12px;
327
+
border-radius: 8px;
153
328
cursor: pointer;
154
329
display: flex;
155
330
justify-content: space-between;
156
331
align-items: center;
157
332
margin-bottom: 4px;
158
333
font-size: 14px;
334
+
transition: all 0.2s;
159
335
}
160
336
161
337
.sidebar-item:hover { background: var(--bg-tertiary); }
162
338
.sidebar-item.active { background: var(--accent); }
163
339
164
-
.sidebar-item .count {
165
-
font-size: 12px;
166
-
padding: 2px 8px;
167
-
border-radius: 10px;
168
-
background: var(--bg-primary);
340
+
.sidebar-item .name {
341
+
white-space: nowrap;
342
+
overflow: hidden;
343
+
text-overflow: ellipsis;
344
+
max-width: 160px;
169
345
}
170
346
171
-
.sidebar-item .count.all-passed { color: var(--success); }
172
-
.sidebar-item .count.has-failed { color: var(--failure); }
173
-
174
-
main {
175
-
margin-left: 300px;
176
-
padding: 20px;
177
-
padding-top: 30px;
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;
178
353
}
179
354
180
-
.intro {
181
-
background: var(--bg-secondary);
182
-
padding: 20px;
183
-
border-radius: 8px;
184
-
margin-bottom: 20px;
185
-
}
355
+
.sidebar-item .badge.all-passed { color: var(--success); }
356
+
.sidebar-item .badge.has-failed { color: var(--failure); }
186
357
358
+
/* File Sections */
187
359
.file-section {
188
-
margin-bottom: 30px;
360
+
margin-bottom: 24px;
189
361
background: var(--bg-secondary);
190
-
border-radius: 8px;
362
+
border-radius: 12px;
191
363
overflow: hidden;
364
+
border: 1px solid var(--border);
192
365
}
193
366
194
367
.file-header {
195
-
padding: 15px 20px;
368
+
padding: 18px 24px;
196
369
background: var(--bg-tertiary);
197
370
cursor: pointer;
198
371
display: flex;
199
372
justify-content: space-between;
200
373
align-items: center;
374
+
transition: background 0.2s;
201
375
}
376
+
377
+
.file-header:hover { background: #1a4a7a; }
202
378
203
379
.file-header h2 {
204
380
font-size: 1.1rem;
205
381
display: flex;
206
382
align-items: center;
207
-
gap: 10px;
383
+
gap: 12px;
208
384
}
209
385
210
386
.file-header .toggle {
211
-
font-size: 1.2rem;
212
-
transition: transform 0.2s;
387
+
font-size: 1rem;
388
+
transition: transform 0.3s;
389
+
color: var(--text-secondary);
213
390
}
214
391
215
392
.file-header.collapsed .toggle { transform: rotate(-90deg); }
216
393
217
394
.file-stats {
218
395
display: flex;
219
-
gap: 15px;
396
+
gap: 20px;
220
397
font-size: 14px;
221
398
}
222
399
223
-
.file-stats .passed { color: var(--success); }
224
-
.file-stats .failed { color: var(--failure); }
400
+
.file-stats .passed { color: var(--success); font-weight: 500; }
401
+
.file-stats .failed { color: var(--failure); font-weight: 500; }
225
402
226
-
.tests-container {
227
-
padding: 10px;
228
-
}
229
-
403
+
.tests-container { padding: 12px; }
230
404
.tests-container.hidden { display: none; }
231
405
406
+
/* Test Items */
232
407
.test-item {
233
408
margin: 8px 0;
234
409
border: 1px solid var(--border);
235
-
border-radius: 6px;
410
+
border-radius: 8px;
236
411
overflow: hidden;
412
+
transition: border-color 0.2s;
237
413
}
238
414
415
+
.test-item:hover { border-color: var(--text-muted); }
416
+
239
417
.test-header {
240
-
padding: 10px 15px;
418
+
padding: 12px 16px;
241
419
cursor: pointer;
242
420
display: flex;
243
421
justify-content: space-between;
244
422
align-items: center;
245
423
background: var(--bg-primary);
424
+
transition: background 0.2s;
246
425
}
247
426
248
-
.test-header:hover { background: var(--bg-tertiary); }
427
+
.test-header:hover { background: rgba(255,255,255,0.03); }
249
428
250
429
.test-header .status {
251
430
width: 10px;
252
431
height: 10px;
253
432
border-radius: 50%;
254
-
margin-right: 10px;
433
+
margin-right: 12px;
434
+
flex-shrink: 0;
255
435
}
256
436
257
437
.test-header .status.passed { background: var(--success); }
···
261
441
flex: 1;
262
442
display: flex;
263
443
align-items: center;
444
+
min-width: 0;
264
445
}
265
446
266
447
.test-header .test-num {
267
448
font-weight: 600;
268
-
margin-right: 10px;
269
-
color: var(--text-secondary);
449
+
margin-right: 12px;
450
+
color: var(--text-muted);
451
+
font-size: 0.9rem;
270
452
}
271
453
272
454
.test-header .test-desc {
···
275
457
white-space: nowrap;
276
458
overflow: hidden;
277
459
text-overflow: ellipsis;
278
-
max-width: 600px;
460
+
}
461
+
462
+
.test-header .expand-icon {
463
+
color: var(--text-muted);
464
+
font-size: 0.8rem;
279
465
}
280
466
467
+
/* Test Details */
281
468
.test-details {
282
-
padding: 15px;
283
-
background: var(--bg-primary);
469
+
padding: 20px;
470
+
background: var(--code-bg);
284
471
border-top: 1px solid var(--border);
285
472
display: none;
286
473
}
···
288
475
.test-details.visible { display: block; }
289
476
290
477
.detail-section {
291
-
margin-bottom: 15px;
478
+
margin-bottom: 20px;
292
479
}
480
+
481
+
.detail-section:last-child { margin-bottom: 0; }
293
482
294
483
.detail-section h4 {
295
-
font-size: 12px;
484
+
font-size: 11px;
296
485
text-transform: uppercase;
297
-
color: var(--text-secondary);
298
-
margin-bottom: 8px;
299
-
letter-spacing: 0.5px;
486
+
letter-spacing: 1px;
487
+
color: var(--text-muted);
488
+
margin-bottom: 10px;
489
+
display: flex;
490
+
align-items: center;
491
+
gap: 8px;
300
492
}
301
493
302
494
.detail-section pre {
303
495
background: var(--bg-secondary);
304
-
padding: 12px;
305
-
border-radius: 6px;
496
+
padding: 16px;
497
+
border-radius: 8px;
306
498
overflow-x: auto;
307
-
font-family: 'Monaco', 'Menlo', monospace;
499
+
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
308
500
font-size: 13px;
309
501
white-space: pre-wrap;
310
-
word-break: break-all;
311
-
max-height: 300px;
502
+
word-break: break-word;
503
+
max-height: 400px;
312
504
overflow-y: auto;
505
+
line-height: 1.5;
506
+
border: 1px solid var(--border);
313
507
}
314
508
315
509
.detail-row {
316
510
display: grid;
317
511
grid-template-columns: 1fr 1fr;
318
-
gap: 15px;
512
+
gap: 20px;
319
513
}
320
514
321
-
.detail-row.single { grid-template-columns: 1fr; }
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
+
}
322
523
323
-
.meta-info {
324
-
display: flex;
325
-
gap: 20px;
326
-
flex-wrap: wrap;
327
-
font-size: 13px;
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 {
328
543
color: var(--text-secondary);
329
-
margin-bottom: 15px;
544
+
margin-bottom: 12px;
330
545
}
331
546
332
-
.meta-info span {
333
-
background: var(--bg-secondary);
334
-
padding: 4px 10px;
335
-
border-radius: 4px;
547
+
.explanation ul {
548
+
list-style: none;
549
+
padding-left: 0;
336
550
}
337
551
338
-
.diff-indicator {
339
-
color: var(--failure);
340
-
font-weight: bold;
341
-
margin-left: 5px;
552
+
.explanation li {
553
+
padding: 8px 0;
554
+
padding-left: 24px;
555
+
position: relative;
556
+
color: var(--text-secondary);
342
557
}
343
558
344
-
@media (max-width: 900px) {
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; }
345
577
.sidebar { display: none; }
346
-
main { margin-left: 0; }
347
578
.detail-row { grid-template-columns: 1fr; }
579
+
.summary-card.large { grid-column: span 1; }
348
580
}
349
581
|}
350
582
···
365
597
e.stopPropagation();
366
598
const details = this.nextElementSibling;
367
599
details.classList.toggle('visible');
600
+
const icon = this.querySelector('.expand-icon');
601
+
if (icon) icon.textContent = details.classList.contains('visible') ? '▲' : '▼';
368
602
});
369
603
});
370
604
···
374
608
const fileId = this.dataset.file;
375
609
const section = document.getElementById(fileId);
376
610
if (section) {
377
-
section.scrollIntoView({ behavior: 'smooth' });
378
-
// Expand if collapsed
611
+
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
379
612
const header = section.querySelector('.file-header');
380
-
if (header.classList.contains('collapsed')) {
613
+
if (header && header.classList.contains('collapsed')) {
381
614
header.click();
382
615
}
383
616
}
384
-
// Update active state
385
617
document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
386
618
this.classList.add('active');
387
619
});
···
396
628
const text = item.textContent.toLowerCase();
397
629
item.style.display = text.includes(query) ? '' : 'none';
398
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
+
});
399
641
});
400
642
}
401
643
···
425
667
document.getElementById('collapse-all')?.addEventListener('click', function() {
426
668
document.querySelectorAll('.file-header:not(.collapsed)').forEach(h => h.click());
427
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
+
});
428
676
});
429
677
|}
430
678
···
448
696
| Some data ->
449
697
Printf.sprintf {|
450
698
<div class="detail-section">
451
-
<h4>Original Test Data (from .dat/.test file)</h4>
699
+
<h4>📄 Source HTML</h4>
452
700
<pre>%s</pre>
453
701
</div>
454
702
|} (html_escape (truncate_string data))
455
703
| None -> ""
456
704
in
457
705
458
-
let diff_indicator = if test.success then "" else {|<span class="diff-indicator">✗</span>|} in
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
459
711
460
712
Printf.sprintf {|
461
713
<div class="test-item" data-passed="%b">
···
465
717
<span class="test-num">#%d</span>
466
718
<span class="test-desc">%s</span>
467
719
</div>
468
-
<span>▼</span>
720
+
<span class="expand-icon">▼</span>
469
721
</div>
470
722
<div class="test-details">
471
723
%s
472
724
<div class="detail-section">
473
-
<h4>Input (HTML to parse)</h4>
725
+
<h4>📥 Input File</h4>
474
726
<pre>%s</pre>
475
727
</div>
476
728
<div class="detail-row">
477
729
<div class="detail-section">
478
-
<h4>Expected Output%s</h4>
730
+
<h4>✓ Expected Output</h4>
479
731
<pre>%s</pre>
480
732
</div>
481
733
<div class="detail-section">
482
-
<h4>Actual Output%s</h4>
734
+
<h4>⚡ Actual Output %s</h4>
483
735
<pre>%s</pre>
484
736
</div>
485
737
</div>
···
487
739
</div>
488
740
</div>
489
741
|} test.success status_class test.test_num desc_escaped
490
-
raw_data_html input_escaped diff_indicator expected_escaped diff_indicator actual_escaped details_html
742
+
raw_data_html input_escaped expected_escaped comparison_label actual_escaped details_html
491
743
492
744
let generate_file_html file =
493
-
let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in
745
+
let file_id = String.map (fun c -> if c = '/' || c = '.' then '-' else c) file.filename in
494
746
let tests_html = String.concat "\n" (List.map generate_test_html file.tests) in
495
747
let collapsed = if file.failed_count = 0 then "collapsed" else "" in
496
748
let hidden = if file.failed_count = 0 then "hidden" else "" in
···
500
752
<div class="file-header %s">
501
753
<h2>
502
754
<span class="toggle">▼</span>
503
-
%s
504
-
<span style="font-weight: normal; font-size: 0.9em; color: var(--text-secondary)">(%s)</span>
755
+
📁 %s
505
756
</h2>
506
757
<div class="file-stats">
507
758
<span class="passed">✓ %d passed</span>
···
512
763
%s
513
764
</div>
514
765
</div>
515
-
|} file_id collapsed file.filename file.test_type file.passed_count file.failed_count hidden tests_html
766
+
|} file_id collapsed file.filename file.passed_count file.failed_count hidden tests_html
516
767
517
768
let generate_sidebar_html files =
518
769
String.concat "\n" (List.map (fun file ->
519
-
let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in
520
-
let count_class = if file.failed_count = 0 then "all-passed" else "has-failed" in
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
521
772
Printf.sprintf {|
522
773
<div class="sidebar-item" data-file="file-%s">
523
-
<span>%s</span>
524
-
<span class="count %s">%d/%d</span>
774
+
<span class="name">%s</span>
775
+
<span class="badge %s">%d/%d</span>
525
776
</div>
526
-
|} file_id file.filename count_class file.passed_count (file.passed_count + file.failed_count)
777
+
|} file_id file.filename badge_class file.passed_count (file.passed_count + file.failed_count)
527
778
) files)
528
779
780
+
let 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
+
818
+
let 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
+
529
846
let generate_report report output_path =
530
847
let files_html = String.concat "\n" (List.map generate_file_html report.files) in
531
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
532
871
533
872
let html = Printf.sprintf {|<!DOCTYPE html>
534
873
<html lang="en">
···
539
878
<style>%s</style>
540
879
</head>
541
880
<body>
542
-
<div class="sidebar">
543
-
<h3 style="padding: 10px; color: var(--text-secondary); font-size: 12px; text-transform: uppercase;">Files</h3>
544
-
%s
545
-
</div>
546
-
547
-
<main>
548
-
<header>
881
+
<div class="container">
882
+
<div class="hero">
549
883
<h1>%s</h1>
550
-
<p style="color: var(--text-secondary); margin: 10px 0; max-width: 900px;">%s</p>
551
-
<div class="summary">
552
-
<span class="stat total">%d tests</span>
553
-
<span class="stat passed">✓ %d passed</span>
554
-
<span class="stat failed">✗ %d failed</span>
555
-
<span class="stat total">%.1f%% pass rate</span>
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
556
890
</div>
557
-
<div class="controls">
558
-
<input type="search" id="search" placeholder="Search tests...">
559
-
<select id="filter">
560
-
<option value="all">All tests</option>
561
-
<option value="passed">Passed only</option>
562
-
<option value="failed">Failed only</option>
563
-
</select>
564
-
<button id="expand-all">Expand All</button>
565
-
<button id="collapse-all">Collapse All</button>
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>
566
900
</div>
567
-
</header>
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
+
568
918
%s
569
-
</main>
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>
570
955
571
956
<script>%s</script>
572
957
</body>
573
958
</html>
574
959
|} report.title css
575
-
sidebar_html
576
960
report.title (html_escape report.description)
577
-
(report.total_passed + report.total_failed)
578
-
report.total_passed
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")
579
967
report.total_failed
580
-
(100.0 *. float_of_int report.total_passed /. float_of_int (max 1 (report.total_passed + report.total_failed)))
581
-
files_html js
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
582
973
in
583
974
584
975
let oc = open_out output_path in
+4
test/test_serializer.ml
+4
test/test_serializer.ml
···
846
846
files = List.rev !file_results;
847
847
total_passed = !total_passed;
848
848
total_failed = !total_failed;
849
+
match_quality = None;
850
+
test_type_breakdown = None;
851
+
strictness_mode = None;
852
+
run_timestamp = None;
849
853
} in
850
854
Report.generate_report report "test_serializer_report.html";
851
855
+4
test/test_tokenizer.ml
+4
test/test_tokenizer.ml
···
396
396
files = List.rev !file_results;
397
397
total_passed = !total_passed;
398
398
total_failed = !total_failed;
399
+
match_quality = None;
400
+
test_type_breakdown = None;
401
+
strictness_mode = None;
402
+
run_timestamp = None;
399
403
} in
400
404
Report.generate_report report "test_tokenizer_report.html";
401
405
+79
-12
test/test_validator.ml
+79
-12
test/test_validator.ml
···
269
269
Printf.printf "No matches: %d\n" no_match;
270
270
Printf.printf "N/A (isvalid or no expected): %d\n" no_quality
271
271
272
+
(** Read HTML source file for display in report *)
273
+
let 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
+
272
281
(** Generate HTML report *)
273
282
let generate_html_report results output_path =
274
283
let by_category = group_by_category results in
···
278
287
let failed_count = List.length tests - passed_count in
279
288
let test_results = List.mapi (fun i r ->
280
289
let outcome_str = match r.file.expected with
281
-
| Valid -> "valid"
282
-
| Invalid -> "invalid"
283
-
| HasWarning -> "has-warning"
290
+
| Valid -> "isvalid"
291
+
| Invalid -> "novalid"
292
+
| HasWarning -> "haswarn"
284
293
| Unknown -> "unknown"
285
294
in
286
-
let description = Printf.sprintf "[%s] %s" outcome_str r.file.relative_path in
295
+
let description = Printf.sprintf "[%s] %s" outcome_str (Filename.basename r.file.relative_path) in
287
296
let expected = match r.expected_message with
288
297
| Some m -> m
289
-
| None -> "(no expected message)"
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)"
290
303
in
291
304
let actual_str =
292
305
let errors = if r.actual_errors = [] then ""
293
-
else "Errors:\n" ^ String.concat "\n" r.actual_errors in
306
+
else "Errors:\n • " ^ String.concat "\n • " r.actual_errors in
294
307
let warnings = if r.actual_warnings = [] then ""
295
-
else "Warnings:\n" ^ String.concat "\n" r.actual_warnings in
308
+
else "Warnings:\n • " ^ String.concat "\n • " r.actual_warnings in
296
309
let infos = if r.actual_infos = [] then ""
297
-
else "Info:\n" ^ String.concat "\n" r.actual_infos in
298
-
if errors = "" && warnings = "" && infos = "" then "(no messages)"
299
-
else String.trim (errors ^ "\n" ^ warnings ^ "\n" ^ infos)
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"
300
319
in
301
320
Report.{
302
321
test_num = i + 1;
···
305
324
expected;
306
325
actual = actual_str;
307
326
success = r.passed;
308
-
details = [("Status", r.details)];
309
-
raw_test_data = None;
327
+
details = [
328
+
("Result", r.details);
329
+
("Match Quality", match_quality_str);
330
+
];
331
+
raw_test_data = read_html_source r.file.path;
310
332
}
311
333
) tests in
312
334
Report.{
···
321
343
let total_passed = List.filter (fun r -> r.passed) results |> List.length in
322
344
let total_failed = List.length results - total_passed in
323
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
+
324
387
let report : Report.report = {
325
388
title = "Nu HTML Validator Tests";
326
389
test_type = "validator";
···
332
395
files = file_results;
333
396
total_passed;
334
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;
335
402
} in
336
403
Report.generate_report report output_path
337
404