[OCaml] High Intensity Training Online
test query markup by class token, not exact attribute bytes
Several web tests asserted on exact attribute sequences such as class="outline" value="Begin under override". Those break the moment an attribute is reordered or a second class is added beside the one under test, which happened during this session. Add has_class and has_attr to the test support. has_class reads each class attribute and matches a whitespace-delimited token, so a check survives neighbouring classes and any attribute order, without pulling an HTML parser into the test suite. Migrate every exact class needle to it and cover the helper itself.
test/web/auth.ml
@@ -17,6 +17,23 @@
17
17
fun () ->
18
18
let c = client () in
19
19
Alcotest.(check int) "GET /login" 200 (status (get c "/login")) );
20
Added:
( "has_class matches a class token regardless of its neighbours",
21
Added:
`Quick,
22
Added:
fun () ->
23
Added:
(* The markup helper the web tests rely on: a class check must
24
Added:
survive another class beside it and any attribute order. *)
25
Added:
let html =
26
Added:
"<a href=\"/x\" class=\"button outline\">Go</a><p \
27
Added:
class=\"eyebrow\"></p>"
28
Added:
in
29
Added:
Alcotest.(check bool) "second token" true (has_class html "outline");
30
Added:
Alcotest.(check bool) "first token" true (has_class html "button");
31
Added:
Alcotest.(check bool)
32
Added:
"other element" true (has_class html "eyebrow");
33
Added:
Alcotest.(check bool)
34
Added:
"a substring of a token is not a match" false
35
Added:
(has_class html "line");
36
Added:
Alcotest.(check bool) "absent" false (has_class html "danger") );
20
37
( "every response carries the browser hardening headers",
21
38
`Quick,
22
39
fun () ->
test/web/logbook.ml
@@ -21,8 +21,7 @@
21
21
(* No workout table, just the empty-state line. *)
22
22
Alcotest.(check bool)
23
23
"no workout table" false
24
Removed:
(Option.is_some
25
Removed:
(index_from ~needle:"class=\"logbook-table\"" page 0));
24
Added:
(has_class page "logbook-table");
26
25
Alcotest.(check bool)
27
26
"empty state when nothing is logged" true
28
27
(Option.is_some (index_from ~needle:"Nothing logged yet." page 0))
@@ -41,12 +40,10 @@
41
40
workout. There is no table and no Duration. *)
42
41
Alcotest.(check bool)
43
42
"the accordion is present" true
44
Removed:
(Option.is_some
45
Removed:
(index_from ~needle:"class=\"logbook-entry\"" page 0));
43
Added:
(has_class page "logbook-entry");
46
44
Alcotest.(check bool)
47
45
"no workout table" false
48
Removed:
(Option.is_some
49
Removed:
(index_from ~needle:"class=\"logbook-table\"" page 0));
46
Added:
(has_class page "logbook-table");
50
47
Alcotest.(check bool)
51
48
"the entry summary carries the date" true
52
49
(Option.is_some (index_from ~needle:"logbook-entry-date" page 0));
@@ -529,8 +526,7 @@
529
526
This is a first performance, so no change is shown. *)
530
527
Alcotest.(check bool)
531
528
"the entry is an accordion" true
532
Removed:
(Option.is_some
533
Removed:
(index_from ~needle:"class=\"logbook-entry\"" page 0));
529
Added:
(has_class page "logbook-entry");
534
530
Alcotest.(check bool)
535
531
"the imported exercise is listed" true
536
532
(Option.is_some (index_from ~needle:"Squats" page 0));
@@ -766,7 +762,7 @@
766
762
let page = body (get c "/logbook") in
767
763
Alcotest.(check bool)
768
764
"the calendar is present" true
769
Removed:
(Option.is_some (index_from ~needle:"class=\"calendar\"" page 0));
765
Added:
(has_class page "calendar");
770
766
(* The month and year label is back in the calendar header. *)
771
767
Alcotest.(check bool)
772
768
"the calendar header shows the month name" true
test/web/overview.ml
@@ -140,8 +140,7 @@
140
140
(index_from ~needle:"value=\"Begin next workout\"" page 0));
141
141
Alcotest.(check bool)
142
142
"the begin submit carries no outline class" false
143
Removed:
(Option.is_some (index_from ~needle:"class=\"outline\"" page 0))
144
Removed:
);
143
Added:
(has_class page "outline") );
145
144
( "begin under override is an outline blood button while recovering",
146
145
`Quick,
147
146
fun () ->
@@ -163,10 +162,7 @@
163
162
(Option.is_some (index_from ~needle:"requires recovery" page 0));
164
163
Alcotest.(check bool)
165
164
"the override submit is styled as an outline button" true
166
Removed:
(Option.is_some
167
Removed:
(index_from
168
Removed:
~needle:"class=\"outline\" value=\"Begin under override\""
169
Removed:
page 0)) );
165
Added:
(has_class page "outline") );
170
166
( "the routine page is served once a routine is selected",
171
167
`Quick,
172
168
fun () ->
test/web/support.ml
@@ -141,6 +141,45 @@
141
141
in
142
142
if start < 0 then None else at start
143
143
144
Added:
(* {2 Markup queries}
145
Added:
146
Added:
Assertions on rendered HTML should survive an attribute being reordered or a
147
Added:
class being added beside the one under test. These helpers match structure
148
Added:
rather than an exact byte sequence. They do not parse HTML; they read the
149
Added:
[class] attribute of each tag and split it on whitespace, which is all a
150
Added:
class check needs and keeps the test suite free of a parser dependency. *)
151
Added:
152
Added:
(* Every [class="..."] attribute value in the page, in document order. *)
153
Added:
let class_attributes html =
154
Added:
let needle = "class=\"" in
155
Added:
let rec collect from acc =
156
Added:
match index_from ~needle html from with
157
Added:
| None -> List.rev acc
158
Added:
| Some i -> (
159
Added:
let start = i + String.length needle in
160
Added:
match String.index_from_opt html start '"' with
161
Added:
| None -> List.rev acc
162
Added:
| Some stop ->
163
Added:
collect (stop + 1) (String.sub html start (stop - start) :: acc))
164
Added:
in
165
Added:
collect 0 []
166
Added:
167
Added:
(* True when some element carries [token] as one of its class names, whatever
168
Added:
other classes sit beside it and in whatever order. *)
169
Added:
let has_class html token =
170
Added:
List.exists
171
Added:
(fun value ->
172
Added:
List.mem token
173
Added:
(String.split_on_char ' ' value |> List.filter (fun s -> s <> "")))
174
Added:
(class_attributes html)
175
Added:
176
Added:
(* True when some element carries the attribute pair exactly. A single pair
177
Added:
matches wherever it sits within its tag, so this is already independent of
178
Added:
attribute order. Named for symmetry with [has_class]. *)
179
Added:
let has_attr html ~name ~value =
180
Added:
Option.is_some
181
Added:
(index_from ~needle:(Printf.sprintf "%s=\"%s\"" name value) html 0)
182
Added:
144
183
(* Pull the CSRF token value out of a rendered form. *)
145
184
let csrf_token html =
146
185
match index_from ~needle:"name=\"dream.csrf\"" html 0 with