Skip to content

Commit 93eb63b

Browse files
More convenient tester (#44)
* Implement more convenient tester * Add unittest
1 parent 791265b commit 93eb63b

File tree

15 files changed

+264
-94
lines changed

15 files changed

+264
-94
lines changed

atcoder-tools

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33

44
from atcodertools.tools.envgen import main as envgen_main
5-
from atcodertools.tools.tester import do_test
5+
from atcodertools.tools.tester import main as tester_main
66

77
if __name__ == '__main__':
88
if len(sys.argv) < 2 or sys.argv[1] not in ("gen", "test"):
@@ -18,4 +18,4 @@ if __name__ == '__main__':
1818
envgen_main(prog, args)
1919

2020
if sys.argv[1] == "test":
21-
do_test()
21+
sys.exit(tester_main(prog, args))

atcodertools/tools/tester.py

Lines changed: 221 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,250 @@
11
#!/usr/bin/python3
2-
2+
import argparse
3+
import logging
34
import sys
45
import os
56
import glob
67
import subprocess
8+
import time
9+
from enum import Enum
710
from pathlib import Path
8-
9-
10-
def print_e(*text, end='\n'):
11-
print(*text, end=end, file=sys.stderr)
12-
13-
14-
FAIL = ''
15-
OKGREEN = ''
16-
OKBLUE = ''
17-
ENDC = ''
11+
from typing import List, Tuple
1812

1913

2014
class NoExecutableFileError(Exception):
2115
pass
2216

2317

24-
class IrregularInOutFileError(Exception):
18+
class IrregularSampleFileError(Exception):
2519
pass
2620

2721

28-
class NoCppFileError(Exception):
29-
pass
22+
class ExecStatus(Enum):
23+
NORMAL = "NORMAL"
24+
TLE = "TLE"
25+
RE = "RE"
3026

3127

32-
class MultipleCppFilesError(Exception):
33-
pass
28+
class ExecResult:
29+
def __init__(self, status: ExecStatus, output: str = None, elapsed_sec: float = None):
30+
self.status = status
31+
self.output = output
32+
if elapsed_sec is not None:
33+
self.elapsed_ms = int(elapsed_sec * 1000 + 0.5)
34+
else:
35+
self.elapsed_ms = None
36+
37+
def is_correct_output(self, answer_text):
38+
return answer_text == self.output
3439

3540

3641
def is_executable_file(file_name):
3742
return os.access(file_name, os.X_OK) and Path(file_name).is_file() \
3843
and file_name.find(".cpp") == -1 and not file_name.endswith(".txt") # cppやtxtを省くのは一応の Cygwin 対策
3944

4045

41-
def do_test(exec_file=None):
42-
exec_files = [
43-
fname for fname in glob.glob(
44-
'./*') if is_executable_file(
45-
fname)]
46-
if exec_file is None:
47-
if len(exec_files) == 0:
48-
raise NoExecutableFileError
49-
exec_file = exec_files[0]
50-
if len(exec_files) >= 2:
51-
print_e("WARNING: There're multiple executable files. This time, '%s' is selected." %
52-
exec_file, "candidates =", exec_files)
53-
54-
infiles = sorted(glob.glob('./in_*.txt'))
55-
outfiles = sorted(glob.glob('./out_*.txt'))
56-
57-
success = 0
58-
total = 0
59-
60-
for infile, outfile in zip(infiles, outfiles):
61-
if os.path.basename(infile)[2:] != os.path.basename(outfile)[3:]:
62-
print_e("The output for '%s' is not '%s'!!!" % (infile, outfile))
63-
raise IrregularInOutFileError
64-
with open(infile, "r") as inf, open(outfile, "r") as ouf:
65-
ans_data = ouf.read()
66-
out_data = ""
67-
status = "WA"
68-
try:
69-
out_data = subprocess.check_output(
70-
[exec_file, ""], stdin=inf, universal_newlines=True, timeout=1)
71-
except subprocess.TimeoutExpired:
72-
status = "TLE(1s)"
73-
except subprocess.CalledProcessError:
74-
status = "RE"
75-
76-
if out_data == ans_data:
77-
status = "PASSED"
78-
print_e("# %s ... %s" % (os.path.basename(infile),
79-
"%s%s%s" % (OKGREEN, status, ENDC)))
80-
success += 1
46+
def infer_exec_file(filenames):
47+
exec_files = [name for name in sorted(
48+
filenames) if is_executable_file(name)]
49+
50+
if len(exec_files) == 0:
51+
raise NoExecutableFileError
52+
53+
exec_file = exec_files[0]
54+
if len(exec_files) >= 2:
55+
logging.warning("{0} {1}".format(
56+
"There're multiple executable files. '{exec_file}' is selected.".format(
57+
exec_file=exec_file),
58+
"The candidates were {exec_files}.".format(exec_files=exec_files)))
59+
return exec_file
60+
61+
62+
def infer_case_num(sample_filename: str):
63+
result = ""
64+
for c in sample_filename:
65+
if c.isdigit():
66+
result += c
67+
return int(result)
68+
69+
70+
def run_program(exec_file: str, input_file: str, timeout_sec: int) -> ExecResult:
71+
try:
72+
elapsed_sec = -time.time()
73+
out_data = subprocess.check_output(
74+
[exec_file, ""], stdin=open(input_file, 'r'), universal_newlines=True, timeout=timeout_sec)
75+
elapsed_sec += time.time()
76+
return ExecResult(ExecStatus.NORMAL, out_data, elapsed_sec)
77+
except subprocess.TimeoutExpired:
78+
return ExecResult(ExecStatus.TLE)
79+
except subprocess.CalledProcessError:
80+
return ExecResult(ExecStatus.RE)
81+
82+
83+
def build_details_str(exec_res: ExecResult, input_file: str, output_file: str) -> str:
84+
res = ""
85+
86+
def append(text: str, end='\n'):
87+
nonlocal res
88+
res += text + end
89+
90+
append("[Input]")
91+
with open(input_file, "r") as f:
92+
append(f.read(), end='')
93+
94+
append("[Expected]")
95+
with open(output_file, "r") as f:
96+
append(f.read(), end='')
97+
98+
if exec_res.status == ExecStatus.NORMAL:
99+
append("[Received]")
100+
if exec_res.status == ExecStatus.NORMAL:
101+
append(exec_res.output, end='')
102+
else:
103+
append("[Log]")
104+
append(exec_res.status.name)
105+
return res
106+
107+
108+
def run_for_samples(exec_file: str, sample_pair_list: List[Tuple[str, str]], timeout_sec: int, knock_out: bool = False):
109+
success_count = 0
110+
for in_sample_file, out_sample_file in sample_pair_list:
111+
# Run program
112+
exec_res = run_program(exec_file, in_sample_file,
113+
timeout_sec=timeout_sec)
114+
115+
# Output header
116+
with open(out_sample_file, 'r') as f:
117+
answer_text = f.read()
118+
119+
is_correct = exec_res.is_correct_output(answer_text)
120+
if is_correct:
121+
message = "PASSED {elapsed} ms".format(elapsed=exec_res.elapsed_ms)
122+
success_count += 1
123+
else:
124+
if exec_res.status == ExecStatus.NORMAL:
125+
message = "WA"
81126
else:
82-
print_e("# %s ... %s" % (os.path.basename(infile),
83-
"%s%s%s" % (FAIL, status, ENDC)))
84-
print_e("[Input]")
85-
with open(infile, "r") as inf2:
86-
print_e(inf2.read(), end='')
87-
print_e("[Expected]")
88-
print_e("%s%s%s" %
89-
(OKBLUE, ans_data, ENDC), end='')
90-
print_e("[Received]")
91-
print_e("%s%s%s" %
92-
(FAIL, out_data, ENDC), end='')
93-
print_e()
94-
total += 1
95-
96-
success_flag = False
97-
if total == 0:
98-
print_e("No test cases")
99-
elif success != total:
100-
print_e("Some cases FAILED (passed %s of %s)" % (success, total))
127+
message = exec_res.status.name
128+
129+
print("# {case_name} ... {message}".format(
130+
case_name=os.path.basename(in_sample_file),
131+
message=message,
132+
))
133+
134+
# Output details for incorrect results.
135+
if not is_correct:
136+
print('{}\n'.format(build_details_str(
137+
exec_res, in_sample_file, out_sample_file)))
138+
if knock_out:
139+
print('Stop testing ...')
140+
break
141+
return success_count
142+
143+
144+
def validate_sample_pair(in_sample_file, out_sample_file):
145+
if os.path.basename(in_sample_file).split("_")[-1] != os.path.basename(out_sample_file).split("_")[-1]:
146+
logging.error(
147+
'The file combination of {} and {} is wrong.'.format(
148+
in_sample_file,
149+
out_sample_file
150+
))
151+
raise IrregularSampleFileError
152+
153+
154+
def run_single_test(exec_file, in_sample_file_list, out_sample_file_list, timeout_sec: int, case_num: int) -> bool:
155+
def single_or_none(lst: List):
156+
if len(lst) == 1:
157+
return lst[0]
158+
if len(lst) == 0:
159+
return None
160+
raise IrregularSampleFileError(
161+
"Multiple samples are detected for given case num: {}".format(lst))
162+
163+
in_sample_file = single_or_none(
164+
[name for name in in_sample_file_list if infer_case_num(name) == case_num])
165+
out_sample_file = single_or_none(
166+
[name for name in out_sample_file_list if infer_case_num(name) == case_num])
167+
168+
if in_sample_file is None or out_sample_file is None:
169+
print("Invalid test case number: {}".format(case_num))
170+
return False
171+
172+
validate_sample_pair(in_sample_file, out_sample_file)
173+
174+
success_count = run_for_samples(
175+
exec_file, [(in_sample_file, out_sample_file)], timeout_sec)
176+
177+
return success_count == 1
178+
179+
180+
def run_all_tests(exec_file, in_sample_file_list, out_sample_file_list, timeout_sec: int, knock_out: bool) -> bool:
181+
if len(in_sample_file_list) != len(out_sample_file_list):
182+
logging.error("{0}{1}{2}".format(
183+
"The number of the sample inputs and outputs are different.\n",
184+
"# of sample inputs: {}\n".format(len(in_sample_file_list)),
185+
"# of sample outputs: {}\n".format(len(out_sample_file_list))))
186+
raise IrregularSampleFileError
187+
samples = []
188+
for in_sample_file, out_sample_file in zip(in_sample_file_list, out_sample_file_list):
189+
validate_sample_pair(in_sample_file, out_sample_file)
190+
samples.append((in_sample_file, out_sample_file))
191+
192+
success_count = run_for_samples(exec_file, samples, timeout_sec, knock_out)
193+
194+
if len(samples) == 0:
195+
print("No test cases")
196+
return False
197+
elif success_count != len(samples):
198+
print("Some cases FAILED (passed {success_count} of {total})".format(
199+
success_count=success_count,
200+
total=len(samples),
201+
))
202+
return False
101203
else:
102-
print_e("Passed all testcases!!!")
103-
success_flag = True
104-
return success_flag
204+
print("Passed all test cases!!!")
205+
return True
206+
207+
208+
def main(prog, args) -> bool:
209+
parser = argparse.ArgumentParser(
210+
prog=prog,
211+
formatter_class=argparse.RawTextHelpFormatter)
212+
213+
parser.add_argument("--exec", '-e',
214+
help="file path to the execution target. Automatically detects an exec file if not specified.",
215+
default=None)
216+
217+
parser.add_argument("--num", '-n',
218+
help="the case number to test (1-origin). All cases are tested if not specified.",
219+
type=int,
220+
default=None)
221+
222+
parser.add_argument("--dir", '-d',
223+
help="target directory to test. [Default] Current directory",
224+
default=".")
225+
226+
parser.add_argument("--timeout", '-t',
227+
help="Timeout for each test cases (sec) [Default] 1",
228+
type=int,
229+
default=1)
230+
231+
parser.add_argument("--knock-out", '-k',
232+
help="Stop execution immediately after any example's failure [Default] False",
233+
action="store_true",
234+
default=False)
235+
236+
args = parser.parse_args(args)
237+
exec_file = args.exec or infer_exec_file(
238+
glob.glob(os.path.join(args.dir, '*')))
239+
in_sample_file_list = sorted(glob.glob(os.path.join(args.dir, 'in_*.txt')))
240+
out_sample_file_list = sorted(
241+
glob.glob(os.path.join(args.dir, 'out_*.txt')))
242+
243+
if args.num is None:
244+
return run_all_tests(exec_file, in_sample_file_list, out_sample_file_list, args.timeout, args.knock_out)
245+
else:
246+
return run_single_test(exec_file, in_sample_file_list, out_sample_file_list, args.timeout, args.num)
105247

106248

107249
if __name__ == "__main__":
108-
if do_test():
109-
sys.exit(0)
110-
else:
111-
sys.exit(-1)
250+
main(sys.argv[0], sys.argv[1:])
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/python3
2+
n = int(input())
3+
print(n + 1)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/python3
2+
raise Exception
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
4

tests/resources/test_tester/test_run_single_test/in_1.txt

Whitespace-only changes.

tests/resources/test_tester/test_run_single_test/in_2.txt

Whitespace-only changes.

0 commit comments

Comments
 (0)