Skip to content

Commit 3f6b43c

Browse files
Upgrade test runner to version 2
1 parent 4a21260 commit 3f6b43c

13 files changed

Lines changed: 402 additions & 32 deletions

File tree

bin/run-tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ for test_dir in tests/*; do
2727
-e 's/\s*Elapsed time: [0-9]+ ms, on [0-9]+ job.\s*//g' \
2828
-e 's/\s*[0-9]+(\.[0-9]+)? ms\s*//g' \
2929
-e 's/\s*[0-9]+ (v?lines|bytes)\s*//g' \
30-
-e 's#v_[0-9]+/tsession[^/]+#tsession#g' \
30+
-e 's#v_[0-9]+/tsession[^/ "]+#tsession#g' \
3131
"${results_file_path}"
3232

3333
echo "${test_dir_name}: comparing results.json to expected_results.json"

bin/run.sh

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
#!/usr/bin/env sh
22

33
# Synopsis:
4-
# Run the test runner on a solution.
5-
4+
# Run the Exercism V test runner on a solution.
5+
#
66
# Arguments:
7-
# $1: exercise slug
8-
# $2: path to solution folder
9-
# $3: path to output directory
10-
7+
# $1: exercise slug
8+
# $2: path to solution folder
9+
# $3: path to output directory
10+
#
1111
# Output:
12-
# Writes the test results to a results.json file in the passed-in output directory.
13-
# The test results are formatted according to the specifications at https://github.com/exercism/docs/blob/main/building/tooling/test-runners/interface.md
12+
# Writes a v2 results.json to the output directory, per
13+
# https://github.com/exercism/docs/blob/main/building/tooling/test-runners/interface.md
1414

1515
# Example:
1616
# ./bin/run.sh two-fer path/to/solution/folder/ path/to/output/directory/
@@ -33,26 +33,99 @@ echo "${slug}: testing..."
3333

3434
cd "${solution_dir}" > /dev/null
3535

36-
# Run the tests for the provided implementation file and redirect stdout and
37-
# stderr to capture it
3836
test_output=$(v -stats test run_test.v 2>&1)
3937
exit_code=$?
4038

4139
cd - > /dev/null
4240

43-
# Write the results.json file based on the exit code of the command that was
44-
# just executed that tested the implementation file
41+
# Strip the solution directory prefix from paths so output is portable,
42+
# and remove ANSI color codes (V emits them even when captured)
43+
test_output=$(printf '%s' "${test_output}" | sed -e "s#${solution_dir}/\{0,1\}##g" -e 's/\x1b\[[0-9;]*m//g')
44+
45+
# ---------- Error case (compile failure) ----------
46+
if [ ${exit_code} -ne 0 ] && echo "${test_output}" | grep -q "error:"; then
47+
jq -n --arg output "${test_output}" \
48+
'{version: 2, status: "error", message: $output}' > "${results_file}"
49+
echo "${slug}: done"
50+
exit 0
51+
fi
52+
53+
# ---------- Extract test_code from the test file (in source order) ----------
54+
# Walks run_test.v line by line. When a "fn test_xxx() {" line is found,
55+
# collects subsequent lines (unindented) as the test body until "}".
56+
test_codes_json=$(jq -Rs '
57+
split("\n") |
58+
reduce .[] as $line (
59+
{inside_test: false, name: "", body: "", result: []};
60+
if .inside_test then
61+
if ($line | test("^}")) then
62+
.result += [{name: .name, test_code: .body}] |
63+
.inside_test = false | .name = "" | .body = ""
64+
else
65+
.body += (if .body != "" then "\n" else "" end)
66+
+ ($line | ltrimstr("\t"))
67+
end
68+
elif ($line | test("^fn test_")) then
69+
.inside_test = true |
70+
.name = ($line | capture("fn (?<n>test_[a-zA-Z0-9_]+)") | .n)
71+
else . end
72+
) | .result
73+
' "${solution_dir}/run_test.v")
74+
75+
# ---------- Parse V test output into per-test results ----------
76+
# V prints one "OK" or "FAIL" line per test with the pattern:
77+
# OK [1/9]... | main.test_xxx()
78+
# FAIL [1/9]... | main.test_xxx()
79+
# Lines between results (file:line, assert expression) are accumulated
80+
# as the failure message for the next FAIL.
81+
test_results_json=$(printf '%s' "${test_output}" | jq -Rs '
82+
def test_name: capture("main\\.(?<n>[^(]+)") | .n;
83+
split("\n") |
84+
reduce .[] as $line (
85+
{pending: "", tests: []};
86+
if ($line | test("OK.*\\| *main\\.")) then
87+
.tests += [{
88+
name: ($line | test_name),
89+
status: "pass"
90+
}] | .pending = ""
91+
elif ($line | test("FAIL.*\\| *main\\.")) then
92+
.tests += [{
93+
name: ($line | test_name),
94+
status: "fail",
95+
message: .pending
96+
}] | .pending = ""
97+
elif ($line | test("^---- Testing|^running tests in:|Summary for|^--------|^Failed command|compilation |generated |V source")) then
98+
.
99+
else
100+
.pending += (if .pending != "" then "\n" + $line else $line end)
101+
end
102+
) | .tests
103+
')
104+
105+
# ---------- Merge in source file order ----------
106+
# Use test_codes order (= source file order) as the authority.
107+
# For each test_code entry, find the matching result by name.
108+
tests_json=$(jq -n \
109+
--argjson codes "${test_codes_json}" \
110+
--argjson results "${test_results_json}" \
111+
'[ $codes[] | . as $c |
112+
($results[] | select(.name == $c.name)) // {status: "error", message: "Test did not run."}
113+
| . + $c
114+
]')
115+
116+
# ---------- Determine overall status ----------
45117
if [ ${exit_code} -eq 0 ]; then
46-
jq -n '{version: 1, status: "pass"}' > ${results_file}
118+
overall="pass"
47119
else
48-
echo "${test_output}" | grep -q "error:"
49-
if [ $? -eq 0 ]; then
50-
status="error"
51-
else
52-
status="fail"
53-
fi
54-
55-
jq -n --arg output "${test_output}" --arg status "${status}" '{version: 1, status: $status, message: $output}' > ${results_file}
120+
overall="fail"
56121
fi
57122

123+
# ---------- Assemble results and truncate output fields to 500 chars ----------
124+
jq -n --arg status "${overall}" --argjson tests "${tests_json}" '
125+
def trunc: if length > 500 then .[:481] + " [output truncated]" else . end;
126+
{version: 2, status: $status, tests: ($tests | map(
127+
if .output then .output |= trunc else . end
128+
))}
129+
' > "${results_file}"
130+
58131
echo "${slug}: done"
Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
{
2-
"version": 1,
2+
"version": 2,
33
"status": "fail",
4-
"message": "---- Testing... ----------------------------------------------------------------\nrunning tests in: /opt/test-runner/tests/example-all-fail/run_test.v\n/opt/test-runner/tests/example-all-fail/run_test.v:4: fn test_not_divisible_by_4\n assert !is_leap_year(2015)\n\n FAIL [1/9]1 assert | main.test_not_divisible_by_4()\n/opt/test-runner/tests/example-all-fail/run_test.v:8: fn test_divisible_by_2_not_by_4\n assert !is_leap_year(1970)\n\n FAIL [2/9]1 assert | main.test_divisible_by_2_not_by_4()\n/opt/test-runner/tests/example-all-fail/run_test.v:12: fn test_divisible_by_4_not_by_100\n assert is_leap_year(1996)\n\n FAIL [3/9]1 assert | main.test_divisible_by_4_not_by_100()\n/opt/test-runner/tests/example-all-fail/run_test.v:16: fn test_divisible_by_4_and_5\n assert is_leap_year(1960)\n\n FAIL [4/9]1 assert | main.test_divisible_by_4_and_5()\n/opt/test-runner/tests/example-all-fail/run_test.v:20: fn test_divisible_by_100_not_by_400\n assert !is_leap_year(2100)\n\n FAIL [5/9]1 assert | main.test_divisible_by_100_not_by_400()\n/opt/test-runner/tests/example-all-fail/run_test.v:24: fn test_divisible_by_100_not_by_3\n assert !is_leap_year(1900)\n\n FAIL [6/9]1 assert | main.test_divisible_by_100_not_by_3()\n/opt/test-runner/tests/example-all-fail/run_test.v:28: fn test_divisible_by_400\n assert is_leap_year(2000)\n\n FAIL [7/9]1 assert | main.test_divisible_by_400()\n/opt/test-runner/tests/example-all-fail/run_test.v:32: fn test_divisible_by_100_not_by_125\n assert is_leap_year(2400)\n\n FAIL [8/9]1 assert | main.test_divisible_by_100_not_by_125()\n/opt/test-runner/tests/example-all-fail/run_test.v:36: fn test_divisible_by_200_not_by_400\n assert !is_leap_year(1800)\n\n FAIL [9/9]1 assert | main.test_divisible_by_200_not_by_400()\n Summary for running V tests in \"run_test.v\": 9 failed, 9 total. Elapsed time:.\n V source code size:,, 267 types, 12 modules, 128 files\ngenerated target code size:,\ncompilation took:, compilation speed:/s\n\n--------------------------------------------------------------------------------\nFailed command 1: '/opt/vlang/v' -stats -o '/tmp/tsession/0_0/run_test' '/opt/test-runner/tests/example-all-fail/run_test.v'\nSummary for all V _test.v files: 1 failed, 1 total.Comptime:. Runtime:."
4+
"tests": [
5+
{
6+
"name": "test_not_divisible_by_4",
7+
"status": "fail",
8+
"message": "run_test.v:4: fn test_not_divisible_by_4\n assert !is_leap_year(2015)\n",
9+
"test_code": "assert !is_leap_year(2015)"
10+
},
11+
{
12+
"name": "test_divisible_by_2_not_by_4",
13+
"status": "fail",
14+
"message": "run_test.v:8: fn test_divisible_by_2_not_by_4\n assert !is_leap_year(1970)\n",
15+
"test_code": "assert !is_leap_year(1970)"
16+
},
17+
{
18+
"name": "test_divisible_by_4_not_by_100",
19+
"status": "fail",
20+
"message": "run_test.v:12: fn test_divisible_by_4_not_by_100\n assert is_leap_year(1996)\n",
21+
"test_code": "assert is_leap_year(1996)"
22+
},
23+
{
24+
"name": "test_divisible_by_4_and_5",
25+
"status": "fail",
26+
"message": "run_test.v:16: fn test_divisible_by_4_and_5\n assert is_leap_year(1960)\n",
27+
"test_code": "assert is_leap_year(1960)"
28+
},
29+
{
30+
"name": "test_divisible_by_100_not_by_400",
31+
"status": "fail",
32+
"message": "run_test.v:20: fn test_divisible_by_100_not_by_400\n assert !is_leap_year(2100)\n",
33+
"test_code": "assert !is_leap_year(2100)"
34+
},
35+
{
36+
"name": "test_divisible_by_100_not_by_3",
37+
"status": "fail",
38+
"message": "run_test.v:24: fn test_divisible_by_100_not_by_3\n assert !is_leap_year(1900)\n",
39+
"test_code": "assert !is_leap_year(1900)"
40+
},
41+
{
42+
"name": "test_divisible_by_400",
43+
"status": "fail",
44+
"message": "run_test.v:28: fn test_divisible_by_400\n assert is_leap_year(2000)\n",
45+
"test_code": "assert is_leap_year(2000)"
46+
},
47+
{
48+
"name": "test_divisible_by_100_not_by_125",
49+
"status": "fail",
50+
"message": "run_test.v:32: fn test_divisible_by_100_not_by_125\n assert is_leap_year(2400)\n",
51+
"test_code": "assert is_leap_year(2400)"
52+
},
53+
{
54+
"name": "test_divisible_by_200_not_by_400",
55+
"status": "fail",
56+
"message": "run_test.v:36: fn test_divisible_by_200_not_by_400\n assert !is_leap_year(1800)\n",
57+
"test_code": "assert !is_leap_year(1800)"
58+
}
59+
]
560
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module main
2+
3+
fn sorted_lower(s string) []rune {
4+
mut r := s.to_lower().runes()
5+
r.sort()
6+
return r
7+
}
8+
9+
fn find_anagrams(subject string, candidates []string) []string {
10+
target := sorted_lower(subject)
11+
mut result := []string{}
12+
for c in candidates {
13+
if c.to_lower() != subject.to_lower() && sorted_lower(c) == target {
14+
result << c
15+
}
16+
}
17+
return result
18+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"version": 2,
3+
"status": "pass",
4+
"tests": [
5+
{
6+
"name": "test_no_matches",
7+
"status": "pass",
8+
"test_code": "candidates := ['hello', 'world', 'zombies', 'pants']\nexpected := []string{}\nassert find_anagrams('diaper', candidates) == expected"
9+
},
10+
{
11+
"name": "test_detects_three_anagrams",
12+
"status": "pass",
13+
"test_code": "candidates := ['gallery', 'ballerina', 'regally', 'clergy', 'largely', 'leading']\nexpected := ['gallery', 'regally', 'largely']\nassert find_anagrams('allergy', candidates) == expected"
14+
},
15+
{
16+
"name": "test_detects_anagrams_case_insensitively",
17+
"status": "pass",
18+
"test_code": "candidates := ['cashregister', 'Carthorse', 'radishes']\nexpected := ['Carthorse']\nassert find_anagrams('Orchestra', candidates) == expected"
19+
},
20+
{
21+
"name": "test_different_characters_may_have_the_same_bytes",
22+
"status": "pass",
23+
"test_code": "candidates := ['€a']\nexpected := []string{}\nassert find_anagrams('a⬂', candidates) == expected"
24+
}
25+
]
26+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module main
2+
3+
fn test_no_matches() {
4+
candidates := ['hello', 'world', 'zombies', 'pants']
5+
expected := []string{}
6+
assert find_anagrams('diaper', candidates) == expected
7+
}
8+
9+
fn test_detects_three_anagrams() {
10+
candidates := ['gallery', 'ballerina', 'regally', 'clergy', 'largely', 'leading']
11+
expected := ['gallery', 'regally', 'largely']
12+
assert find_anagrams('allergy', candidates) == expected
13+
}
14+
15+
fn test_detects_anagrams_case_insensitively() {
16+
candidates := ['cashregister', 'Carthorse', 'radishes']
17+
expected := ['Carthorse']
18+
assert find_anagrams('Orchestra', candidates) == expected
19+
}
20+
21+
fn test_different_characters_may_have_the_same_bytes() {
22+
candidates := ['€a']
23+
expected := []string{}
24+
assert find_anagrams('a⬂', candidates) == expected
25+
}

0 commit comments

Comments
 (0)