ej-test-gen 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Sergey Shashkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ej_test_gen
3
+ Version: 0.4.1
4
+ Summary: Create tests for ejudge and co
5
+ Author-email: Sergey Shashkov <sh57@yandex.ru>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ShashkovS/ej_test_gen
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # Create tests for ejudge using solution
14
+
15
+ ## Install
16
+ ```bash
17
+ pip install git+https://github.com/ShashkovS/ej_test_gen.git --user --upgrade
18
+ ```
19
+
20
+ ## Example:
21
+
22
+ ```python
23
+ # sol.py
24
+ n = int(input())
25
+ fct = 1
26
+ for i in range(2, n + 1):
27
+ fct *= i
28
+ print(fct)
29
+ ```
30
+
31
+ ```python
32
+ # test_creator.py
33
+ from ej_test_gen import TestRunner, random
34
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
35
+ # runner = TestRunner(solution="sol.cpp", tests_dir="tests", use_WSL=True)
36
+
37
+ runner.test("""3""")
38
+ runner.test("""5""")
39
+
40
+ for tests_in_group, group_max in [(2, 10), (5, 50)]:
41
+ for __ in range(tests_in_group):
42
+ n = random.randint(0, group_max)
43
+ test = f'{n}'
44
+ runner.test(test)
45
+ ```
46
+
47
+
48
+ ```bash
49
+ > python test_creator.py
50
+ 001: 3 --> 6¶ Done! 0.27c
51
+ 002: 2 --> 2¶ Done! 0.25c
52
+ 003: 3 --> 6¶ Done! 0.26c
53
+ 004: 31 --> 8222838654177922817725562880000000¶ Done! 0.25c
54
+ 005: 10 --> 3628800¶ Done! 0.26c
55
+ 006: 15 --> 1307674368000¶ Done! 0.26c
56
+ 007: 41 --> 3345252661316380710817006205344075166515 Done! 0.27c
57
+ 008: 15 --> 1307674368000¶ Done! 0.27c
58
+ ```
59
+
60
+ `TestRunner` resolves relative `solution` and `tests_dir` paths from the
61
+ directory of the script where `TestRunner` is created. This means `sol.py` and
62
+ `test_creator.py` can live in the same directory, and generated tests will be
63
+ written to `tests/` next to them even if you run the script from another
64
+ directory:
65
+
66
+ ```bash
67
+ python path/to/test_creator.py
68
+ ```
69
+
70
+ Pass `working_dir` explicitly when you want a different base directory. For
71
+ example, `working_dir="."` keeps the old behavior where relative paths are
72
+ resolved from the process current working directory.
73
+
74
+ ## Solution errors
75
+
76
+ Use `on_error` to choose what happens when the solution exits with a non-zero
77
+ return code or writes to stderr:
78
+
79
+ ```python
80
+ # default: stop generation and raise RuntimeError
81
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
82
+
83
+ # ignore: skip failed tests and keep generating the next ones
84
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="ignore")
85
+
86
+ # output: write stderr/traceback to the answer file
87
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="output")
88
+ ```
89
+
90
+ Old names are still accepted for compatibility: `on_error="raise"` means
91
+ `on_error="default"`, and `on_error="skip"` means `on_error="ignore"`.
92
+
93
+
94
+ # License
95
+
96
+ This is free and unencumbered software released into the public domain.
97
+
98
+ Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
@@ -0,0 +1,86 @@
1
+ # Create tests for ejudge using solution
2
+
3
+ ## Install
4
+ ```bash
5
+ pip install git+https://github.com/ShashkovS/ej_test_gen.git --user --upgrade
6
+ ```
7
+
8
+ ## Example:
9
+
10
+ ```python
11
+ # sol.py
12
+ n = int(input())
13
+ fct = 1
14
+ for i in range(2, n + 1):
15
+ fct *= i
16
+ print(fct)
17
+ ```
18
+
19
+ ```python
20
+ # test_creator.py
21
+ from ej_test_gen import TestRunner, random
22
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
23
+ # runner = TestRunner(solution="sol.cpp", tests_dir="tests", use_WSL=True)
24
+
25
+ runner.test("""3""")
26
+ runner.test("""5""")
27
+
28
+ for tests_in_group, group_max in [(2, 10), (5, 50)]:
29
+ for __ in range(tests_in_group):
30
+ n = random.randint(0, group_max)
31
+ test = f'{n}'
32
+ runner.test(test)
33
+ ```
34
+
35
+
36
+ ```bash
37
+ > python test_creator.py
38
+ 001: 3 --> 6¶ Done! 0.27c
39
+ 002: 2 --> 2¶ Done! 0.25c
40
+ 003: 3 --> 6¶ Done! 0.26c
41
+ 004: 31 --> 8222838654177922817725562880000000¶ Done! 0.25c
42
+ 005: 10 --> 3628800¶ Done! 0.26c
43
+ 006: 15 --> 1307674368000¶ Done! 0.26c
44
+ 007: 41 --> 3345252661316380710817006205344075166515 Done! 0.27c
45
+ 008: 15 --> 1307674368000¶ Done! 0.27c
46
+ ```
47
+
48
+ `TestRunner` resolves relative `solution` and `tests_dir` paths from the
49
+ directory of the script where `TestRunner` is created. This means `sol.py` and
50
+ `test_creator.py` can live in the same directory, and generated tests will be
51
+ written to `tests/` next to them even if you run the script from another
52
+ directory:
53
+
54
+ ```bash
55
+ python path/to/test_creator.py
56
+ ```
57
+
58
+ Pass `working_dir` explicitly when you want a different base directory. For
59
+ example, `working_dir="."` keeps the old behavior where relative paths are
60
+ resolved from the process current working directory.
61
+
62
+ ## Solution errors
63
+
64
+ Use `on_error` to choose what happens when the solution exits with a non-zero
65
+ return code or writes to stderr:
66
+
67
+ ```python
68
+ # default: stop generation and raise RuntimeError
69
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
70
+
71
+ # ignore: skip failed tests and keep generating the next ones
72
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="ignore")
73
+
74
+ # output: write stderr/traceback to the answer file
75
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="output")
76
+ ```
77
+
78
+ Old names are still accepted for compatibility: `on_error="raise"` means
79
+ `on_error="default"`, and `on_error="skip"` means `on_error="ignore"`.
80
+
81
+
82
+ # License
83
+
84
+ This is free and unencumbered software released into the public domain.
85
+
86
+ Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ej_test_gen"
7
+ version = "0.4.1" # Версию нужно будет поддерживать вручную
8
+ description = "Create tests for ejudge and co"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Sergey Shashkov", email = "sh57@yandex.ru"}
12
+ ]
13
+ license = {text = "MIT"}
14
+ requires-python = ">=3.8"
15
+
16
+ dependencies = [
17
+ ]
18
+
19
+ [project.urls]
20
+ "Homepage" = "https://github.com/ShashkovS/ej_test_gen"
21
+
22
+ [tool.setuptools]
23
+ package-dir = {"" = "src"}
24
+ include-package-data = true # Включает все указанные данные в пакет
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ "*" = ["*.txt", "*.rst", "*.in", "*.png"]
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=8.3.5",
35
+ ]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+
40
+ [project.scripts]
41
+ # Здесь можно добавить консольные команды, если нужно
42
+ # пример: "command_name" = "module:function"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ __all__ = [
2
+ "__version__",
3
+ "__license__",
4
+ "__author__",
5
+ "__copyright__",
6
+ ]
7
+
8
+ __version__ = '0.4.1'
9
+ __author__ = "Sergey Shashkov"
10
+ __license__ = "MIT"
11
+ __copyright__ = "Copyright 2019- Sergey Shashkov"
@@ -0,0 +1,51 @@
1
+ """
2
+ # ejudge test generator
3
+
4
+ # Example1:
5
+ from ej_test_gen import TestRunner, random
6
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
7
+ runner.test("3")
8
+
9
+ # sol.py
10
+ n = int(input())
11
+ fct = 1
12
+ for i in range(2, n + 1):
13
+ fct *= i
14
+ print(fct)
15
+
16
+ # Example2:
17
+ runner2 = TestRunner(
18
+ solution='sol.py',
19
+ tests_dir='tests',
20
+
21
+ test_name_template='{:02}',
22
+ test_is_binary=False,
23
+ test_encoding="utf-8",
24
+
25
+ ans_name_template='{:02}.a',
26
+ ans_encoding="utf-8",
27
+ ans_is_binary=False,
28
+
29
+ py_executable=sys.executable,
30
+ cpp_compiler="g++",
31
+ timeout=5,
32
+ use_WSL=False,
33
+ compilation_timeout=30,
34
+
35
+ # 'default': raise RuntimeError when solution fails
36
+ # 'ignore': skip failed tests
37
+ # 'output': save stderr/traceback as the answer
38
+ on_error='default',
39
+ )
40
+
41
+ # By default, relative solution and tests_dir paths are resolved from the
42
+ # directory of the script creating TestRunner. Pass working_dir explicitly
43
+ # when you want another base directory; working_dir='.' resolves paths from
44
+ # the process current working directory.
45
+ #
46
+ # Old on_error aliases are still accepted:
47
+ # 'raise' -> 'default', 'skip' -> 'ignore'.
48
+ """
49
+
50
+ from .__about__ import *
51
+ from .ej_test_gen import *
@@ -0,0 +1,353 @@
1
+ # -*- coding: utf-8 -*-.
2
+ import inspect
3
+ import subprocess
4
+ import os
5
+ import time
6
+ import sys
7
+ import logging
8
+ import random
9
+ import platform
10
+
11
+ random.seed(2000)
12
+ logging.basicConfig(level=logging.INFO)
13
+ lg = logging.getLogger('Runner')
14
+
15
+ __all__ = ['TestRunner', 'random']
16
+
17
+ _is_windows = platform.system() == 'Windows'
18
+ _ON_ERROR_ALIASES = {
19
+ 'default': 'default',
20
+ 'raise': 'default',
21
+ 'ignore': 'ignore',
22
+ 'skip': 'ignore',
23
+ 'output': 'output',
24
+ }
25
+
26
+ class TestRunner:
27
+ __test__ = False
28
+
29
+ solution: str
30
+ working_dir: str
31
+ tests_dir: str
32
+
33
+ test_name_template: str
34
+ test_is_binary: bool
35
+ test_encoding: str
36
+
37
+ ans_name_template: str
38
+ ans_encoding: str
39
+ ans_is_binary: bool
40
+
41
+ py_executable: str
42
+ cpp_compiler: str
43
+ timeout: int
44
+ use_WSL: bool
45
+ compilation_timeout: int
46
+
47
+ on_error: str # 'default' | 'ignore' | 'output'
48
+
49
+ def __init__(
50
+ self,
51
+ solution='sol.py',
52
+ working_dir=None,
53
+ tests_dir='.',
54
+
55
+ test_name_template='{:02}',
56
+ test_is_binary=False,
57
+ test_encoding="utf-8",
58
+
59
+ ans_name_template='{:02}.a',
60
+ ans_encoding="utf-8",
61
+ ans_is_binary=False,
62
+
63
+ py_executable=sys.executable,
64
+ cpp_compiler="g++",
65
+ timeout=5,
66
+ use_WSL=False,
67
+ compilation_timeout=30,
68
+
69
+ on_error='default',
70
+ ):
71
+ self.__dict__.update({k: v for k, v in locals().items() if k != 'self'})
72
+
73
+ self.solution = os.fspath(self.solution)
74
+ if self.tests_dir is not None:
75
+ self.tests_dir = os.fspath(self.tests_dir)
76
+ self.on_error = self._normalize_on_error(self.on_error)
77
+
78
+ # working_dir=None: default to the directory of the script creating TestRunner.
79
+ # Explicit relative working_dir values keep the old cwd-based behavior.
80
+ if self.working_dir is None:
81
+ self.working_dir = self._default_working_dir()
82
+ else:
83
+ self.working_dir = os.fspath(self.working_dir)
84
+ if not os.path.isabs(self.working_dir):
85
+ self.working_dir = os.path.normpath(os.path.join(os.getcwd(), self.working_dir))
86
+ # tests_dir: relative paths resolved from working_dir
87
+ if not os.path.isabs(self.tests_dir):
88
+ self.tests_dir = os.path.normpath(os.path.join(self.working_dir, self.tests_dir))
89
+
90
+ os.makedirs(self.tests_dir, exist_ok=True)
91
+ self.compile_sol()
92
+ self._clean_up()
93
+
94
+ @staticmethod
95
+ def _default_working_dir():
96
+ frame = inspect.currentframe()
97
+ try:
98
+ caller_frame = frame.f_back.f_back if frame and frame.f_back else None
99
+ filename = caller_frame.f_code.co_filename if caller_frame else None
100
+ if filename and not filename.startswith('<'):
101
+ return os.path.dirname(os.path.abspath(filename))
102
+ return os.getcwd()
103
+ finally:
104
+ del frame
105
+
106
+ @staticmethod
107
+ def _normalize_on_error(on_error):
108
+ try:
109
+ return _ON_ERROR_ALIASES[on_error]
110
+ except KeyError:
111
+ allowed = "', '".join(sorted(_ON_ERROR_ALIASES))
112
+ raise ValueError("on_error must be one of: '{}'".format(allowed))
113
+
114
+ def __repr__(self):
115
+ return (f'{self.__class__.__name__}('
116
+ f' {self.solution=!r}, '
117
+ f' {self.working_dir=!r}, '
118
+ f' {self.tests_dir=!r}, '
119
+ f' {self.test_name_template=!r}, '
120
+ f' {self.test_is_binary=!r}, '
121
+ f' {self.test_encoding=!r}, '
122
+ f' {self.ans_name_template=!r}, '
123
+ f' {self.ans_encoding=!r}, '
124
+ f' {self.ans_is_binary=!r}, '
125
+ f' {self.py_executable=!r}, '
126
+ f' {self.cpp_compiler=!r}, '
127
+ f' {self.timeout=!r}, '
128
+ f' {self.use_WSL=!r}, '
129
+ f' {self.on_error=!r}'
130
+ f')')
131
+
132
+ def _run(self, to_stdin):
133
+ if self.test_is_binary:
134
+ input_data = to_stdin
135
+ else:
136
+ input_data = bytes(to_stdin, encoding=self.test_encoding)
137
+ st = time.time()
138
+ if self._compiled:
139
+ to_run = [self._compiled if os.path.isabs(self._compiled) else './' + self._compiled]
140
+ else: # TODO Вообще-то, это если питон
141
+ to_run = [self.py_executable, self.solution]
142
+ if self.use_WSL and _is_windows:
143
+ to_run = 'bash -c "{}"'.format(' '.join(to_run))
144
+ elif _is_windows:
145
+ to_run = ' '.join(to_run)
146
+ lg.debug(to_run)
147
+ pr = subprocess.Popen(
148
+ to_run,
149
+ stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE,
150
+ cwd=self.working_dir
151
+ )
152
+ stdout_bytes, stderr_bytes = pr.communicate(input=input_data, timeout=self.timeout)
153
+ dur = time.time() - st
154
+ if self.ans_is_binary:
155
+ from_stdout = stdout_bytes
156
+ else:
157
+ from_stdout = stdout_bytes.decode(self.ans_encoding, errors="ignore")
158
+ from_stdout = from_stdout.replace('\r\n', '\n').replace('\r', '\n')
159
+ trash_pos = from_stdout.find('pydev debugger:')
160
+ if trash_pos >= 0:
161
+ from_stdout = from_stdout[:trash_pos]
162
+ return from_stdout, stderr_bytes, pr.returncode, dur
163
+
164
+ def _clean_up(self):
165
+ # Удаляем старые тесты
166
+ for filename in os.listdir(self.tests_dir):
167
+ testname = filename
168
+ if testname.endswith('.a'):
169
+ testname = testname[:-2]
170
+ if testname.isdigit():
171
+ os.remove(os.path.join(self.tests_dir, filename))
172
+
173
+ @staticmethod
174
+ def _list_test_files(path):
175
+ """Find all files in given path which names contains only decimal digits.
176
+ Such files are considered as test files.
177
+ Result is sorted by int(test_name)"""
178
+ test_files = [tname for tname in os.listdir(path)
179
+ if tname.isdecimal() and os.path.isfile(os.path.join(path, tname))]
180
+ test_files.sort(key=lambda x: int(x))
181
+ return test_files
182
+
183
+ @staticmethod
184
+ def _prc_text_for_console(text, is_binary=False, max_len=40):
185
+ if is_binary:
186
+ show_text = str(text[:max_len])[:max_len]
187
+ else:
188
+ show_text = text[:max_len]
189
+ return show_text.replace('\r\n', '¶').replace('\n', '¶').replace('\r', '¶')
190
+
191
+ @staticmethod
192
+ def _cmp_two_outputs(output1, output2):
193
+ eq = output1.rstrip() == output2.rstrip()
194
+ return eq, ''
195
+
196
+ @staticmethod
197
+ def _read_test_or_ans(file_full_path, is_binary, encoding):
198
+ open_parms = dict(file=file_full_path, mode='r')
199
+ if is_binary:
200
+ open_parms['mode'] = 'rb'
201
+ else:
202
+ open_parms['encoding'] = encoding
203
+ open_parms['errors'] = "ignore"
204
+ with open(**open_parms) as f:
205
+ test_data = f.read()
206
+ return test_data
207
+
208
+ def _stderr_as_answer(self, stderr_bytes, returncode):
209
+ if stderr_bytes:
210
+ if self.ans_is_binary:
211
+ return stderr_bytes
212
+ return stderr_bytes.decode(self.ans_encoding, errors='replace').replace('\r\n', '\n').replace('\r', '\n')
213
+ fallback = 'Solution exited with returncode={}\n'.format(returncode)
214
+ if self.ans_is_binary:
215
+ return fallback.encode(self.ans_encoding, errors='replace')
216
+ return fallback
217
+
218
+ def _run_given_tests(self, test_files):
219
+ for tname in test_files:
220
+ lg.info('Processing test ' + tname + '...')
221
+
222
+ # First we read the test
223
+ try:
224
+ test_data = self._read_test_or_ans(
225
+ os.path.join(self.tests_dir, tname), self.test_is_binary, self.test_encoding
226
+ )
227
+ except Exception as e:
228
+ lg.error('Error while reading test ' + tname + ': ' + str(e))
229
+ continue
230
+ # Then we read the result
231
+ tname += self.ans_suffix
232
+ try:
233
+ ans_data = self._read_test_or_ans(
234
+ os.path.join(self.tests_dir, tname), self.ans_is_binary, self.ans_encoding
235
+ )
236
+ except Exception as e:
237
+ lg.error('Error while reading test result for ' + tname + ': ' + str(e))
238
+ continue
239
+
240
+ # Ok, now we are ready to run pgm
241
+ try:
242
+ from_stdout, stderr_bytes, returncode, dur = self._run(to_stdin=test_data)
243
+ except Exception as e:
244
+ lg.error('Error while running test ' + tname + ': ' + str(e))
245
+ continue
246
+
247
+ show_test = self._prc_text_for_console(test_data, self.test_is_binary)
248
+ show_res = self._prc_text_for_console(from_stdout, self.ans_is_binary)
249
+ show_ans = self._prc_text_for_console(ans_data, self.ans_is_binary)
250
+
251
+ eq, description = self._cmp_two_outputs(ans_data, from_stdout)
252
+ msg = 'Test {}, {}. Dur:{:0.2f}. {} -> {} (Corr: {})'.format(
253
+ tname, "OK" if eq else "WA", dur, show_test, show_res, show_ans
254
+ )
255
+ if eq:
256
+ lg.info(msg)
257
+ else:
258
+ lg.error(msg)
259
+
260
+ def compile_sol(self):
261
+ self._compiled = None
262
+ name, _, ext = self.solution.rpartition('.')
263
+ ext = ext.lower()
264
+ if ext == 'py':
265
+ return
266
+ elif ext == 'cpp':
267
+ self._compiled = '{}.exe'.format(name) if _is_windows else name
268
+ compiled_path = self._compiled
269
+ if not os.path.isabs(compiled_path):
270
+ compiled_path = os.path.join(self.working_dir, compiled_path)
271
+ if os.path.isfile(compiled_path):
272
+ os.remove(compiled_path)
273
+ cmd = [self.cpp_compiler, self.solution, '-o', self._compiled]
274
+ if self.use_WSL and _is_windows:
275
+ cmd = 'bash -c "{}"'.format(' '.join(cmd))
276
+ elif _is_windows:
277
+ cmd = ' '.join(cmd)
278
+
279
+ lg.debug(cmd)
280
+ pr = subprocess.Popen(
281
+ cmd,
282
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
283
+ cwd=self.working_dir
284
+ )
285
+ stdout, stderr = pr.communicate(timeout=self.compilation_timeout)
286
+ lg.debug('stdout={}\nstderr={}'.format(stdout, stderr))
287
+ if stderr:
288
+ raise EnvironmentError(stderr.decode('utf-8', 'ignore'))
289
+
290
+ def run_test(self):
291
+ test_files = self._list_test_files(self.tests_dir)
292
+ self._run_given_tests(test_files)
293
+
294
+ def test(self, test, *, _test_num=[0], _max_len=40):
295
+ test = test.strip()
296
+ if not self.test_is_binary:
297
+ test += '\n'
298
+ text_prt = self._prc_text_for_console(test, self.test_is_binary)
299
+ _test_num[0] += 1
300
+ _test_num_str = self.test_name_template.format(_test_num[0])
301
+ _ans_num_str = self.ans_name_template.format(_test_num[0])
302
+ print(
303
+ '{}: {}{} --> '.format(
304
+ _test_num_str, text_prt[:_max_len].ljust(_max_len),
305
+ '...' if text_prt[_max_len:] else ' '
306
+ ), end=''
307
+ )
308
+
309
+ ans, stderr_bytes, returncode, dur = self._run(test)
310
+ if not self.ans_is_binary and not ans.endswith('\n'):
311
+ ans += '\n'
312
+
313
+ failed = returncode != 0 or bool(stderr_bytes)
314
+ if failed:
315
+ stderr_preview = stderr_bytes.decode('utf-8', errors='replace')[:200] if stderr_bytes else ''
316
+ if self.on_error == 'default':
317
+ print('ERROR (returncode={})'.format(returncode))
318
+ raise RuntimeError(
319
+ 'Test {}: solution exited with returncode={}, stderr={!r}'.format(
320
+ _test_num_str, returncode, stderr_preview
321
+ )
322
+ )
323
+ elif self.on_error == 'ignore':
324
+ print('skipped (returncode={}{})'.format(
325
+ returncode,
326
+ ', stderr: ' + stderr_preview if stderr_preview else ''
327
+ ))
328
+ _test_num[0] -= 1
329
+ return
330
+ else: # 'output'
331
+ ans = self._stderr_as_answer(stderr_bytes, returncode)
332
+
333
+ ans_prt = self._prc_text_for_console(ans, self.ans_is_binary)
334
+ if dur <= self.timeout:
335
+ print(
336
+ '{}{} Done! {:.2}c'.format(
337
+ ans_prt[:_max_len].ljust(_max_len),
338
+ '...' if ans_prt[_max_len:] else ' ', dur
339
+ )
340
+ )
341
+
342
+ with open(os.path.join(self.tests_dir, _test_num_str), 'w' + ('b' if self.test_is_binary else '')) as f:
343
+ f.write(test)
344
+ with open(os.path.join(self.tests_dir, _ans_num_str), 'w' + ('b' if self.ans_is_binary else '')) as f:
345
+ f.write(ans)
346
+ else:
347
+ print('timeout', '{:.2}c'.format(dur))
348
+
349
+
350
+ if __name__ == '__main__':
351
+ # runner = TestRunner()
352
+ # runner.run_test()
353
+ print('sudo rm -rf, are you sure? Ok, type "password".')
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ej_test_gen
3
+ Version: 0.4.1
4
+ Summary: Create tests for ejudge and co
5
+ Author-email: Sergey Shashkov <sh57@yandex.ru>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ShashkovS/ej_test_gen
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # Create tests for ejudge using solution
14
+
15
+ ## Install
16
+ ```bash
17
+ pip install git+https://github.com/ShashkovS/ej_test_gen.git --user --upgrade
18
+ ```
19
+
20
+ ## Example:
21
+
22
+ ```python
23
+ # sol.py
24
+ n = int(input())
25
+ fct = 1
26
+ for i in range(2, n + 1):
27
+ fct *= i
28
+ print(fct)
29
+ ```
30
+
31
+ ```python
32
+ # test_creator.py
33
+ from ej_test_gen import TestRunner, random
34
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
35
+ # runner = TestRunner(solution="sol.cpp", tests_dir="tests", use_WSL=True)
36
+
37
+ runner.test("""3""")
38
+ runner.test("""5""")
39
+
40
+ for tests_in_group, group_max in [(2, 10), (5, 50)]:
41
+ for __ in range(tests_in_group):
42
+ n = random.randint(0, group_max)
43
+ test = f'{n}'
44
+ runner.test(test)
45
+ ```
46
+
47
+
48
+ ```bash
49
+ > python test_creator.py
50
+ 001: 3 --> 6¶ Done! 0.27c
51
+ 002: 2 --> 2¶ Done! 0.25c
52
+ 003: 3 --> 6¶ Done! 0.26c
53
+ 004: 31 --> 8222838654177922817725562880000000¶ Done! 0.25c
54
+ 005: 10 --> 3628800¶ Done! 0.26c
55
+ 006: 15 --> 1307674368000¶ Done! 0.26c
56
+ 007: 41 --> 3345252661316380710817006205344075166515 Done! 0.27c
57
+ 008: 15 --> 1307674368000¶ Done! 0.27c
58
+ ```
59
+
60
+ `TestRunner` resolves relative `solution` and `tests_dir` paths from the
61
+ directory of the script where `TestRunner` is created. This means `sol.py` and
62
+ `test_creator.py` can live in the same directory, and generated tests will be
63
+ written to `tests/` next to them even if you run the script from another
64
+ directory:
65
+
66
+ ```bash
67
+ python path/to/test_creator.py
68
+ ```
69
+
70
+ Pass `working_dir` explicitly when you want a different base directory. For
71
+ example, `working_dir="."` keeps the old behavior where relative paths are
72
+ resolved from the process current working directory.
73
+
74
+ ## Solution errors
75
+
76
+ Use `on_error` to choose what happens when the solution exits with a non-zero
77
+ return code or writes to stderr:
78
+
79
+ ```python
80
+ # default: stop generation and raise RuntimeError
81
+ runner = TestRunner(solution="sol.py", tests_dir="tests")
82
+
83
+ # ignore: skip failed tests and keep generating the next ones
84
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="ignore")
85
+
86
+ # output: write stderr/traceback to the answer file
87
+ runner = TestRunner(solution="sol.py", tests_dir="tests", on_error="output")
88
+ ```
89
+
90
+ Old names are still accepted for compatibility: `on_error="raise"` means
91
+ `on_error="default"`, and `on_error="skip"` means `on_error="ignore"`.
92
+
93
+
94
+ # License
95
+
96
+ This is free and unencumbered software released into the public domain.
97
+
98
+ Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/ej_test_gen/__about__.py
5
+ src/ej_test_gen/__init__.py
6
+ src/ej_test_gen/ej_test_gen.py
7
+ src/ej_test_gen.egg-info/PKG-INFO
8
+ src/ej_test_gen.egg-info/SOURCES.txt
9
+ src/ej_test_gen.egg-info/dependency_links.txt
10
+ src/ej_test_gen.egg-info/top_level.txt
11
+ tests/test_runner.py
@@ -0,0 +1 @@
1
+ ej_test_gen
@@ -0,0 +1,579 @@
1
+ import os
2
+ import platform
3
+ import sqlite3
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
12
+ from ej_test_gen import TestRunner
13
+
14
+
15
+ def write_solution(tmp_path, code):
16
+ sol = tmp_path / 'sol.py'
17
+ sol.write_text(code, encoding='utf-8')
18
+ return str(sol)
19
+
20
+
21
+ def get_input_files(tmp_path):
22
+ """Return sorted list of test input files (names are all digits)."""
23
+ files = [f for f in tmp_path.iterdir() if f.name.isdecimal()]
24
+ return sorted(files, key=lambda f: int(f.name))
25
+
26
+
27
+ def get_answer_file(input_file):
28
+ return input_file.parent / (input_file.name + '.a')
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Text input / text output
33
+ # ---------------------------------------------------------------------------
34
+
35
+ class TestTextIO:
36
+ def test_echo(self, tmp_path):
37
+ sol = write_solution(tmp_path, "print(input())")
38
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
39
+ runner.test('hello')
40
+
41
+ inputs = get_input_files(tmp_path)
42
+ assert len(inputs) == 1
43
+ assert inputs[0].read_text(encoding='utf-8') == 'hello\n'
44
+ assert get_answer_file(inputs[0]).read_text(encoding='utf-8').strip() == 'hello'
45
+
46
+ def test_text_files_have_final_newline(self, tmp_path):
47
+ sol = write_solution(tmp_path, "import sys; sys.stdout.write(input())")
48
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
49
+ runner.test('hello\n\n')
50
+
51
+ input_file = get_input_files(tmp_path)[0]
52
+ assert input_file.read_bytes() == b'hello\n'
53
+ assert get_answer_file(input_file).read_bytes() == b'hello\n'
54
+
55
+ def test_arithmetic(self, tmp_path):
56
+ sol = write_solution(tmp_path, "a, b = map(int, input().split()); print(a + b)")
57
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
58
+ runner.test('3 4')
59
+
60
+ inputs = get_input_files(tmp_path)
61
+ assert get_answer_file(inputs[0]).read_text(encoding='utf-8').strip() == '7'
62
+
63
+ def test_multiple_tests(self, tmp_path):
64
+ sol = write_solution(tmp_path, "print(int(input()) * 2)")
65
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
66
+ runner.test('3')
67
+ runner.test('5')
68
+
69
+ inputs = get_input_files(tmp_path)
70
+ assert len(inputs) == 2
71
+ answers = [get_answer_file(f).read_text().strip() for f in inputs]
72
+ assert answers == ['6', '10']
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Binary input / text output
77
+ # ---------------------------------------------------------------------------
78
+
79
+ class TestBinaryInput:
80
+ def test_binary_stdin_byte_count(self, tmp_path):
81
+ """Binary input (arbitrary bytes) → text output: length of data."""
82
+ code = "import sys; data = sys.stdin.buffer.read(); print(len(data))"
83
+ sol = write_solution(tmp_path, code)
84
+ binary_data = bytes(range(256))
85
+
86
+ runner = TestRunner(
87
+ solution=sol,
88
+ working_dir=str(tmp_path),
89
+ test_is_binary=True,
90
+ ans_is_binary=False,
91
+ )
92
+ runner.test(binary_data)
93
+
94
+ inputs = get_input_files(tmp_path)
95
+ assert inputs[0].read_bytes() == binary_data
96
+ assert get_answer_file(inputs[0]).read_text().strip() == '256'
97
+
98
+ def test_binary_stdin_null_bytes(self, tmp_path):
99
+ """Binary input containing null bytes (common in binary files)."""
100
+ code = "import sys; d = sys.stdin.buffer.read(); print(d.count(0))"
101
+ sol = write_solution(tmp_path, code)
102
+ binary_data = bytes([0x00, 0x01, 0x00, 0xFF, 0x00]) # three null bytes
103
+
104
+ runner = TestRunner(
105
+ solution=sol,
106
+ working_dir=str(tmp_path),
107
+ test_is_binary=True,
108
+ ans_is_binary=False,
109
+ )
110
+ runner.test(binary_data)
111
+
112
+ inputs = get_input_files(tmp_path)
113
+ assert inputs[0].read_bytes() == binary_data
114
+ assert get_answer_file(inputs[0]).read_text().strip() == '3'
115
+
116
+ def test_sqlite_database_input(self, tmp_path):
117
+ """Binary input is a SQLite database file."""
118
+ # Build a tiny DB
119
+ db_path = tmp_path / 'input.db'
120
+ conn = sqlite3.connect(str(db_path))
121
+ conn.execute('CREATE TABLE nums (val INTEGER)')
122
+ conn.execute('INSERT INTO nums VALUES (42)')
123
+ conn.commit()
124
+ conn.close()
125
+ db_bytes = db_path.read_bytes()
126
+
127
+ code = '''
128
+ import sys, sqlite3, tempfile, os
129
+ data = sys.stdin.buffer.read()
130
+ with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
131
+ f.write(data)
132
+ fname = f.name
133
+ conn = sqlite3.connect(fname)
134
+ row = conn.execute('SELECT val FROM nums').fetchone()
135
+ conn.close()
136
+ os.unlink(fname)
137
+ print(row[0])
138
+ '''
139
+ sol = write_solution(tmp_path, code)
140
+ runner = TestRunner(
141
+ solution=sol,
142
+ working_dir=str(tmp_path),
143
+ test_is_binary=True,
144
+ ans_is_binary=False,
145
+ )
146
+ runner.test(db_bytes)
147
+
148
+ inputs = get_input_files(tmp_path)
149
+ assert inputs[0].read_bytes() == db_bytes
150
+ assert get_answer_file(inputs[0]).read_text().strip() == '42'
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Text input / binary output
155
+ # ---------------------------------------------------------------------------
156
+
157
+ class TestBinaryOutput:
158
+ def test_text_in_binary_out(self, tmp_path):
159
+ """Text input → binary output: emit n sequential bytes."""
160
+ code = "import sys; n = int(input()); sys.stdout.buffer.write(bytes(range(n)))"
161
+ sol = write_solution(tmp_path, code)
162
+
163
+ runner = TestRunner(
164
+ solution=sol,
165
+ working_dir=str(tmp_path),
166
+ test_is_binary=False,
167
+ ans_is_binary=True,
168
+ )
169
+ runner.test('5')
170
+
171
+ inputs = get_input_files(tmp_path)
172
+ assert inputs[0].read_text().strip() == '5'
173
+ assert get_answer_file(inputs[0]).read_bytes() == bytes(range(5))
174
+
175
+ def test_text_in_binary_out_with_nulls(self, tmp_path):
176
+ """Binary output must preserve null bytes."""
177
+ code = "import sys; sys.stdout.buffer.write(bytes([0, 1, 0, 2, 0]))"
178
+ sol = write_solution(tmp_path, code)
179
+
180
+ runner = TestRunner(
181
+ solution=sol,
182
+ working_dir=str(tmp_path),
183
+ test_is_binary=False,
184
+ ans_is_binary=True,
185
+ )
186
+ runner.test('ignored')
187
+
188
+ inputs = get_input_files(tmp_path)
189
+ assert get_answer_file(inputs[0]).read_bytes() == bytes([0, 1, 0, 2, 0])
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Binary input / binary output
194
+ # ---------------------------------------------------------------------------
195
+
196
+ class TestBinaryInputOutput:
197
+ def test_invert_bytes(self, tmp_path):
198
+ """Binary in/out: bitwise invert every byte."""
199
+ code = "import sys; d = sys.stdin.buffer.read(); sys.stdout.buffer.write(bytes(b ^ 0xFF for b in d))"
200
+ sol = write_solution(tmp_path, code)
201
+
202
+ input_bytes = bytes([0x00, 0xFF, 0xAA, 0x55])
203
+ expected = bytes([0xFF, 0x00, 0x55, 0xAA])
204
+
205
+ runner = TestRunner(
206
+ solution=sol,
207
+ working_dir=str(tmp_path),
208
+ test_is_binary=True,
209
+ ans_is_binary=True,
210
+ )
211
+ runner.test(input_bytes)
212
+
213
+ inputs = get_input_files(tmp_path)
214
+ assert inputs[0].read_bytes() == input_bytes
215
+ assert get_answer_file(inputs[0]).read_bytes() == expected
216
+
217
+ def test_image_like_binary(self, tmp_path):
218
+ """Binary in/out: strip first 8 bytes (simulate PNG header extraction)."""
219
+ png_header = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
220
+ input_bytes = png_header + bytes(range(100))
221
+
222
+ code = "import sys; d = sys.stdin.buffer.read(); sys.stdout.buffer.write(d[:8])"
223
+ sol = write_solution(tmp_path, code)
224
+
225
+ runner = TestRunner(
226
+ solution=sol,
227
+ working_dir=str(tmp_path),
228
+ test_is_binary=True,
229
+ ans_is_binary=True,
230
+ )
231
+ runner.test(input_bytes)
232
+
233
+ inputs = get_input_files(tmp_path)
234
+ assert inputs[0].read_bytes() == input_bytes
235
+ assert get_answer_file(inputs[0]).read_bytes() == png_header
236
+
237
+ def test_binary_passthrough_large(self, tmp_path):
238
+ """Binary in/out: 1 KB of non-whitespace bytes are faithfully preserved."""
239
+ # Avoid trailing whitespace bytes (0x00–0x08, 0x0e–0x1f, 0x21–0xff are safe)
240
+ input_bytes = bytes([((i * 7 + 33) % 223) + 33 for i in range(1024)])
241
+ code = "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"
242
+ sol = write_solution(tmp_path, code)
243
+ runner = TestRunner(
244
+ solution=sol,
245
+ working_dir=str(tmp_path),
246
+ test_is_binary=True,
247
+ ans_is_binary=True,
248
+ )
249
+ runner.test(input_bytes)
250
+
251
+ inputs = get_input_files(tmp_path)
252
+ assert inputs[0].read_bytes() == input_bytes
253
+ assert get_answer_file(inputs[0]).read_bytes() == input_bytes
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # use_WSL=True ignored on non-Windows
258
+ # ---------------------------------------------------------------------------
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # tests_dir
262
+ # ---------------------------------------------------------------------------
263
+
264
+ class TestTestsDir:
265
+ def test_default_tests_dir_equals_working_dir(self, tmp_path):
266
+ """Default tests_dir='.' stores files directly in working_dir."""
267
+ sol = write_solution(tmp_path, "print(int(input()) + 1)")
268
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
269
+ runner.test('9')
270
+
271
+ inputs = get_input_files(tmp_path)
272
+ assert len(inputs) == 1
273
+ assert get_answer_file(inputs[0]).read_text().strip() == '10'
274
+
275
+ def test_relative_tests_dir_created_automatically(self, tmp_path):
276
+ """Relative tests_dir is created under working_dir automatically."""
277
+ sol = write_solution(tmp_path, "print(int(input()) * 3)")
278
+ subdir = tmp_path / 'generated'
279
+ assert not subdir.exists()
280
+
281
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), tests_dir='generated')
282
+ assert subdir.exists()
283
+
284
+ runner.test('7')
285
+ inputs = get_input_files(subdir)
286
+ assert len(inputs) == 1
287
+ assert get_answer_file(inputs[0]).read_text().strip() == '21'
288
+ # Root working_dir must stay clean of test files
289
+ assert get_input_files(tmp_path) == []
290
+
291
+ def test_nested_relative_tests_dir_created(self, tmp_path):
292
+ """Multi-level relative tests_dir is created with makedirs."""
293
+ sol = write_solution(tmp_path, "print('hi')")
294
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), tests_dir='a/b/c')
295
+ nested = tmp_path / 'a' / 'b' / 'c'
296
+ assert nested.exists()
297
+
298
+ runner.test('x')
299
+ assert len(get_input_files(nested)) == 1
300
+
301
+ def test_absolute_tests_dir(self, tmp_path):
302
+ """Absolute tests_dir path is used as-is regardless of working_dir."""
303
+ sol_dir = tmp_path / 'sol'
304
+ sol_dir.mkdir()
305
+ out_dir = tmp_path / 'out'
306
+ # out_dir does not exist yet
307
+
308
+ sol = write_solution(sol_dir, "print(42)")
309
+ runner = TestRunner(
310
+ solution=str(sol),
311
+ working_dir=str(sol_dir),
312
+ tests_dir=str(out_dir), # absolute
313
+ )
314
+ assert out_dir.exists()
315
+
316
+ runner.test('ignored')
317
+ assert len(get_input_files(out_dir)) == 1
318
+ assert get_input_files(sol_dir) == []
319
+
320
+ def test_existing_tests_dir_ok(self, tmp_path):
321
+ """If tests_dir already exists, no error is raised."""
322
+ subdir = tmp_path / 'tests'
323
+ subdir.mkdir()
324
+ sol = write_solution(tmp_path, "print('x')")
325
+ # Should not raise even though the directory exists
326
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), tests_dir='tests')
327
+ runner.test('y')
328
+ assert len(get_input_files(subdir)) == 1
329
+
330
+ def test_cleanup_only_affects_tests_dir(self, tmp_path):
331
+ """_clean_up removes old test files only from tests_dir, not working_dir root."""
332
+ sol = write_solution(tmp_path, "print('ok')")
333
+ subdir = tmp_path / 'tests'
334
+
335
+ # Pre-populate tests_dir with stale test files
336
+ subdir.mkdir()
337
+ (subdir / '01').write_text('old input')
338
+ (subdir / '01.a').write_text('old answer')
339
+
340
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), tests_dir='tests')
341
+ # _clean_up is called in __init__; stale files should be gone
342
+ assert not (subdir / '01').exists()
343
+ assert not (subdir / '01.a').exists()
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # working_dir resolution
348
+ # ---------------------------------------------------------------------------
349
+
350
+ class TestWorkingDir:
351
+ def test_default_working_dir_is_creator_script_dir(self, tmp_path):
352
+ """Without working_dir, resolve sol.py and tests_dir relative to gen.py."""
353
+ src_path = Path(__file__).parent.parent / 'src'
354
+ task_dir = tmp_path / 'task'
355
+ task_dir.mkdir()
356
+ (task_dir / 'sol.py').write_text("print(input())\n", encoding='utf-8')
357
+ (task_dir / 'gen.py').write_text(
358
+ "import sys\n"
359
+ f"sys.path.insert(0, {str(src_path)!r})\n"
360
+ "from ej_test_gen import TestRunner\n"
361
+ "runner = TestRunner(solution='sol.py', tests_dir='tests')\n"
362
+ "runner.test('hello')\n",
363
+ encoding='utf-8',
364
+ )
365
+
366
+ result = subprocess.run(
367
+ [sys.executable, str(task_dir / 'gen.py')],
368
+ cwd=str(tmp_path),
369
+ capture_output=True,
370
+ text=True,
371
+ check=False,
372
+ )
373
+
374
+ assert result.returncode == 0, result.stderr
375
+ assert (task_dir / 'tests' / '01').read_text(encoding='utf-8') == 'hello\n'
376
+ assert (task_dir / 'tests' / '01.a').read_text(encoding='utf-8').strip() == 'hello'
377
+ assert not (tmp_path / 'tests').exists()
378
+
379
+ def test_explicit_relative_working_dir_uses_process_cwd(self, tmp_path):
380
+ """Explicit working_dir='.' keeps the old cwd-based behavior."""
381
+ src_path = Path(__file__).parent.parent / 'src'
382
+ cwd_dir = tmp_path / 'cwd'
383
+ task_dir = tmp_path / 'task'
384
+ cwd_dir.mkdir()
385
+ task_dir.mkdir()
386
+ (cwd_dir / 'sol.py').write_text("print(input()[::-1])\n", encoding='utf-8')
387
+ (task_dir / 'gen.py').write_text(
388
+ "import sys\n"
389
+ f"sys.path.insert(0, {str(src_path)!r})\n"
390
+ "from ej_test_gen import TestRunner\n"
391
+ "runner = TestRunner(solution='sol.py', working_dir='.', tests_dir='tests')\n"
392
+ "runner.test('abc')\n",
393
+ encoding='utf-8',
394
+ )
395
+
396
+ result = subprocess.run(
397
+ [sys.executable, str(task_dir / 'gen.py')],
398
+ cwd=str(cwd_dir),
399
+ capture_output=True,
400
+ text=True,
401
+ check=False,
402
+ )
403
+
404
+ assert result.returncode == 0, result.stderr
405
+ assert (cwd_dir / 'tests' / '01').read_text(encoding='utf-8') == 'abc\n'
406
+ assert (cwd_dir / 'tests' / '01.a').read_text(encoding='utf-8').strip() == 'cba'
407
+ assert not (task_dir / 'tests').exists()
408
+
409
+ def test_constructor_does_not_change_process_cwd(self, tmp_path):
410
+ """TestRunner should use subprocess cwd without changing the caller's cwd."""
411
+ cwd_before = Path.cwd()
412
+ sol = write_solution(tmp_path, "print(input())")
413
+
414
+ TestRunner(solution=sol, working_dir=tmp_path)
415
+
416
+ assert Path.cwd() == cwd_before
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # on_error behaviour
421
+ # ---------------------------------------------------------------------------
422
+
423
+ class TestOnError:
424
+ def test_default_on_nonzero_exit(self, tmp_path):
425
+ """on_error='default' raises RuntimeError on non-zero returncode."""
426
+ sol = write_solution(tmp_path, "raise ValueError('boom')")
427
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
428
+
429
+ with pytest.raises(RuntimeError, match='returncode'):
430
+ runner.test('anything')
431
+
432
+ def test_default_on_stderr(self, tmp_path):
433
+ """on_error='default' raises RuntimeError when solution writes to stderr."""
434
+ code = "import sys; sys.stderr.write('oops\\n'); print('ok')"
435
+ sol = write_solution(tmp_path, code)
436
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
437
+
438
+ with pytest.raises(RuntimeError):
439
+ runner.test('anything')
440
+
441
+ def test_default_no_files_created(self, tmp_path):
442
+ """When on_error='default', no test files are written before the exception."""
443
+ sol = write_solution(tmp_path, "raise RuntimeError('x')")
444
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path))
445
+
446
+ with pytest.raises(RuntimeError):
447
+ runner.test('x')
448
+
449
+ assert get_input_files(tmp_path) == []
450
+
451
+ def test_ignore_on_nonzero_exit(self, tmp_path):
452
+ """on_error='ignore' silently skips tests where solution crashes."""
453
+ sol = write_solution(tmp_path, "raise ValueError('boom')")
454
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='ignore')
455
+
456
+ # Must not raise
457
+ runner.test('anything')
458
+ assert get_input_files(tmp_path) == []
459
+
460
+ def test_ignore_does_not_advance_counter(self, tmp_path):
461
+ """Ignored tests don't consume a test number slot."""
462
+ code = """\
463
+ import sys
464
+ n = int(input())
465
+ if n < 0:
466
+ raise ValueError('negative')
467
+ print(n * 2)
468
+ """
469
+ sol = write_solution(tmp_path, code)
470
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='ignore')
471
+
472
+ runner.test('3') # succeeds → file '01' (or some N)
473
+ runner.test('-1') # crashes → skipped, counter not advanced
474
+ runner.test('5') # succeeds → file 'N+1'
475
+
476
+ inputs = get_input_files(tmp_path)
477
+ assert len(inputs) == 2
478
+ answers = [get_answer_file(f).read_text().strip() for f in inputs]
479
+ assert answers == ['6', '10']
480
+ # The two files must be consecutive
481
+ nums = [int(f.name) for f in inputs]
482
+ assert nums[1] == nums[0] + 1
483
+
484
+ def test_ignore_on_stderr(self, tmp_path):
485
+ """on_error='ignore' skips tests that produce stderr output."""
486
+ code = "import sys; sys.stderr.write('warn\\n'); print('result')"
487
+ sol = write_solution(tmp_path, code)
488
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='ignore')
489
+
490
+ runner.test('x')
491
+ assert get_input_files(tmp_path) == []
492
+
493
+ def test_output_uses_stderr_as_answer(self, tmp_path):
494
+ """on_error='output' writes traceback/stderr as the answer file."""
495
+ sol = write_solution(tmp_path, "raise ValueError('boom')")
496
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='output')
497
+
498
+ runner.test('anything')
499
+
500
+ inputs = get_input_files(tmp_path)
501
+ assert len(inputs) == 1
502
+ assert inputs[0].read_text(encoding='utf-8') == 'anything\n'
503
+ answer = get_answer_file(inputs[0]).read_text(encoding='utf-8')
504
+ assert 'Traceback' in answer
505
+ assert 'ValueError: boom' in answer
506
+
507
+ def test_output_without_stderr_uses_returncode_message(self, tmp_path):
508
+ """on_error='output' has deterministic output even without stderr."""
509
+ sol = write_solution(tmp_path, "import os; os._exit(7)")
510
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='output')
511
+
512
+ runner.test('anything')
513
+
514
+ inputs = get_input_files(tmp_path)
515
+ assert get_answer_file(inputs[0]).read_text(encoding='utf-8') == 'Solution exited with returncode=7\n'
516
+
517
+ def test_old_raise_alias_still_works(self, tmp_path):
518
+ """on_error='raise' remains an alias for on_error='default'."""
519
+ sol = write_solution(tmp_path, "raise ValueError('boom')")
520
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='raise')
521
+
522
+ with pytest.raises(RuntimeError, match='returncode'):
523
+ runner.test('anything')
524
+
525
+ def test_old_skip_alias_still_works(self, tmp_path):
526
+ """on_error='skip' remains an alias for on_error='ignore'."""
527
+ sol = write_solution(tmp_path, "raise ValueError('boom')")
528
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='skip')
529
+
530
+ runner.test('anything')
531
+ assert get_input_files(tmp_path) == []
532
+
533
+ def test_invalid_on_error(self, tmp_path):
534
+ """Unknown on_error values fail fast in TestRunner construction."""
535
+ sol = write_solution(tmp_path, "print('ok')")
536
+
537
+ with pytest.raises(ValueError, match='on_error'):
538
+ TestRunner(solution=sol, working_dir=str(tmp_path), on_error='unknown')
539
+
540
+ def test_clean_run_not_affected(self, tmp_path):
541
+ """on_error='default' must not interfere with completely clean solutions."""
542
+ sol = write_solution(tmp_path, "print(input()[::-1])")
543
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), on_error='default')
544
+
545
+ runner.test('abc')
546
+ inputs = get_input_files(tmp_path)
547
+ assert get_answer_file(inputs[0]).read_text().strip() == 'cba'
548
+
549
+
550
+ # ---------------------------------------------------------------------------
551
+ # use_WSL=True ignored on non-Windows
552
+ # ---------------------------------------------------------------------------
553
+
554
+ class TestWSLOnNonWindows:
555
+ def test_use_wsl_ignored(self, tmp_path):
556
+ """use_WSL=True must not crash or break execution on non-Windows."""
557
+ if platform.system() == 'Windows':
558
+ pytest.skip('WSL is meaningful on Windows; skip non-Windows check')
559
+
560
+ sol = write_solution(tmp_path, "print(int(input()) + 1)")
561
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), use_WSL=True)
562
+ runner.test('41')
563
+
564
+ inputs = get_input_files(tmp_path)
565
+ assert get_answer_file(inputs[0]).read_text().strip() == '42'
566
+
567
+ def test_compile_sol_use_wsl_ignored(self, tmp_path):
568
+ """compile_sol() must not crash with use_WSL=True on non-Windows."""
569
+ if platform.system() == 'Windows':
570
+ pytest.skip('WSL is meaningful on Windows; skip non-Windows check')
571
+
572
+ # A Python solution — compile_sol() returns early for .py, but the
573
+ # TestRunner constructor must succeed without errors.
574
+ sol = write_solution(tmp_path, "print('ok')")
575
+ runner = TestRunner(solution=sol, working_dir=str(tmp_path), use_WSL=True)
576
+ runner.test('x')
577
+
578
+ inputs = get_input_files(tmp_path)
579
+ assert get_answer_file(inputs[0]).read_text().strip() == 'ok'