294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
1 |
# IVLE - Informatics Virtual Learning Environment
|
2 |
# Copyright (C) 2007-2008 The University of Melbourne
|
|
3 |
#
|
|
4 |
# This program is free software; you can redistribute it and/or modify
|
|
5 |
# it under the terms of the GNU General Public License as published by
|
|
6 |
# the Free Software Foundation; either version 2 of the License, or
|
|
7 |
# (at your option) any later version.
|
|
8 |
#
|
|
9 |
# This program is distributed in the hope that it will be useful,
|
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12 |
# GNU General Public License for more details.
|
|
13 |
#
|
|
14 |
# You should have received a copy of the GNU General Public License
|
|
15 |
# along with this program; if not, write to the Free Software
|
|
16 |
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
|
17 |
||
18 |
# Module: TestFramework
|
|
19 |
# Author: Dilshan Angampitiya
|
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
20 |
# Steven Bird (revisions)
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
21 |
# Date: 24/1/2008
|
22 |
||
23 |
# Brief description of the Module# define custom exceptions
|
|
24 |
# use exceptions for all errors found in testing
|
|
25 |
||
26 |
import sys, StringIO, copy |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
27 |
import types |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
28 |
|
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
29 |
# student error or author error
|
30 |
# errors in student code get handled internally
|
|
31 |
# errors in solution code get passed up
|
|
32 |
class ScriptExecutionError(Exception): |
|
33 |
"""Runtime error in the student code or solution code"""
|
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
34 |
def __init__(self, exc_info): |
35 |
cla, exc, trbk = exc_info |
|
36 |
self._name = cla.__name__ |
|
37 |
self._detail = str(exc) |
|
310
by dilshan_a
Add lineno to exception info. |
38 |
self._trbk = trbk |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
39 |
|
40 |
def is_critical(self): |
|
41 |
if ( self._name == 'FunctionNotFoundError' |
|
42 |
or self._name == 'SyntaxError' |
|
43 |
or self._name == 'IndentationError'): |
|
44 |
return True |
|
45 |
else: |
|
46 |
return False |
|
47 |
||
48 |
def to_dict(self): |
|
310
by dilshan_a
Add lineno to exception info. |
49 |
import traceback |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
50 |
return {'name': self._name, |
51 |
'detail': self._detail, |
|
311
by dilshan_a
Last commit was buggy. |
52 |
'critical': self.is_critical(), |
310
by dilshan_a
Add lineno to exception info. |
53 |
'lineno': traceback.tb_lineno(self._trbk) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
54 |
}
|
55 |
||
56 |
def __str__(self): |
|
57 |
return self._name + " - " + str(self._detail) |
|
58 |
||
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
59 |
# author error
|
60 |
class TestCreationError(Exception): |
|
61 |
"""An error occured while creating the test suite or one of its components"""
|
|
62 |
def __init__(self, reason): |
|
63 |
self._reason = reason |
|
64 |
||
65 |
def __str__(self): |
|
66 |
return self._reason |
|
67 |
||
68 |
# author error
|
|
69 |
class TestError(Exception): |
|
70 |
"""Runtime error in the testing framework outside of the provided or student code"""
|
|
71 |
def __init__(self, exc_info): |
|
72 |
cla, exc, trbk = exc_info |
|
73 |
self._name = cla.__name__ |
|
74 |
self._detail = str(exc) |
|
75 |
self._exc_info = exc_info |
|
76 |
||
77 |
def exc_info(self): |
|
78 |
return self._exc_info |
|
79 |
||
80 |
def __str__(self): |
|
81 |
return "Error testing solution against attempt: %s - %s" %(self._name, self._detail) |
|
82 |
||
83 |
# author error
|
|
84 |
# raised when expected file not found in solution output
|
|
85 |
# Always gets caught and passed up as a TestError
|
|
86 |
class FileNotFoundError(Exception): |
|
87 |
def __init__(self, filename): |
|
88 |
self._filename = filename |
|
89 |
||
90 |
def __str__(self): |
|
91 |
return "File %s not found in output" %(self._filename) |
|
92 |
||
93 |
||
94 |
# Error encountered when executing solution or attempt code
|
|
95 |
# Always gets caught and passed up in a ScriptExecutionError
|
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
96 |
class FunctionNotFoundError(Exception): |
97 |
"""This error is returned when a function was expected in a
|
|
98 |
test case but was not found"""
|
|
99 |
def __init__(self, function_name): |
|
100 |
self.function_name = function_name |
|
101 |
||
102 |
def __str__(self): |
|
103 |
return "Function " + self.function_name + " not found" |
|
104 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
105 |
class TestCasePart: |
106 |
"""
|
|
107 |
A part of a test case which compares a subset of the input files or file streams.
|
|
502
by stevenbird
www/apps/tutorialservice/__init__.py |
108 |
This can be done either with a comparison function, or by comparing directly, after
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
109 |
applying normalisations.
|
110 |
"""
|
|
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
111 |
|
112 |
ident = classmethod(lambda x: x) |
|
113 |
ignore = classmethod(lambda x: None) |
|
114 |
match = classmethod(lambda x,y: x==y) |
|
115 |
always_match = classmethod(lambda x,y: True) |
|
116 |
true = classmethod(lambda *x: True) |
|
117 |
false = classmethod(lambda *x: False) |
|
118 |
||
502
by stevenbird
www/apps/tutorialservice/__init__.py |
119 |
def __init__(self, pass_msg, fail_msg, default='match'): |
120 |
"""Initialise with descriptions (pass,fail) and a default behavior for output
|
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
121 |
If default is match, unspecified files are matched exactly
|
122 |
If default is ignore, unspecified files are ignored
|
|
123 |
The default default is match.
|
|
124 |
"""
|
|
502
by stevenbird
www/apps/tutorialservice/__init__.py |
125 |
self._pass_msg = pass_msg |
126 |
self._fail_msg = fail_msg |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
127 |
self._default = default |
128 |
if default == 'ignore': |
|
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
129 |
self._default_func = self.true |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
130 |
else: |
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
131 |
self._default_func = self.match |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
132 |
|
133 |
self._file_tests = {} |
|
134 |
self._stdout_test = ('check', self._default_func) |
|
135 |
self._stderr_test = ('check', self._default_func) |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
136 |
self._exception_test = ('check', self._default_func) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
137 |
self._result_test = ('check', self._default_func) |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
138 |
self._code_test = ('check', self._default_func) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
139 |
|
140 |
def _set_default_function(self, function, test_type): |
|
141 |
""""Ensure test type is valid and set function to a default
|
|
142 |
if not specified"""
|
|
143 |
||
144 |
if test_type not in ['norm', 'check']: |
|
145 |
raise TestCreationError("Invalid test type in %s" %self._desc) |
|
146 |
||
147 |
if function == '': |
|
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
148 |
if test_type == 'norm': function = self.ident |
149 |
else: function = self.match |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
150 |
|
151 |
return function |
|
152 |
||
153 |
def _validate_function(self, function, included_code): |
|
154 |
"""Create a function object from the given string.
|
|
502
by stevenbird
www/apps/tutorialservice/__init__.py |
155 |
If a valid function object cannot be created, raise an error.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
156 |
"""
|
157 |
if not callable(function): |
|
158 |
try: |
|
159 |
exec "__f__ = %s" %function in included_code |
|
160 |
except: |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
161 |
raise TestCreationError("Invalid function %s" % function) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
162 |
|
163 |
f = included_code['__f__'] |
|
164 |
||
165 |
if not callable(f): |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
166 |
raise TestCreationError("Invalid function %s" % function) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
167 |
else: |
168 |
f = function |
|
169 |
||
170 |
return f |
|
171 |
||
172 |
def validate_functions(self, included_code): |
|
173 |
"""Ensure all functions used by the test cases exist and are callable.
|
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
174 |
Also convert their string representations to function objects.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
175 |
This can only be done once all the include code has been specified.
|
176 |
"""
|
|
177 |
(test_type, function) = self._stdout_test |
|
178 |
self._stdout_test = (test_type, self._validate_function(function, included_code)) |
|
179 |
||
180 |
(test_type, function) = self._stderr_test |
|
181 |
self._stderr_test = (test_type, self._validate_function(function, included_code)) |
|
716
by mattgiuca
Test Framework: Numerous bug fixes. |
182 |
|
183 |
(test_type, function) = self._result_test |
|
184 |
self._result_test = (test_type, self._validate_function(function, included_code)) |
|
185 |
||
186 |
(test_type, function) = self._exception_test |
|
187 |
self._exception_test = (test_type, self._validate_function(function, included_code)) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
188 |
|
189 |
for filename, (test_type, function) in self._file_tests.items(): |
|
190 |
self._file_tests[filename] = (test_type, self._validate_function(function, included_code)) |
|
191 |
||
192 |
def add_result_test(self, function, test_type='norm'): |
|
193 |
"Test part that compares function return values"
|
|
194 |
function = self._set_default_function(function, test_type) |
|
195 |
self._result_test = (test_type, function) |
|
196 |
||
197 |
def add_stdout_test(self, function, test_type='norm'): |
|
198 |
"Test part that compares stdout"
|
|
199 |
function = self._set_default_function(function, test_type) |
|
200 |
self._stdout_test = (test_type, function) |
|
201 |
||
202 |
def add_stderr_test(self, function, test_type='norm'): |
|
203 |
"Test part that compares stderr"
|
|
204 |
function = self._set_default_function(function, test_type) |
|
205 |
self._stderr_test = (test_type, function) |
|
206 |
||
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
207 |
def add_exception_test(self, function, test_type='norm'): |
208 |
"Test part that compares stderr"
|
|
209 |
function = self._set_default_function(function, test_type) |
|
210 |
self._exception_test = (test_type, function) |
|
211 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
212 |
def add_file_test(self, filename, function, test_type='norm'): |
213 |
"Test part that compares the contents of a specified file"
|
|
214 |
function = self._set_default_function(function, test_type) |
|
215 |
self._file_tests[filename] = (test_type, function) |
|
216 |
||
502
by stevenbird
www/apps/tutorialservice/__init__.py |
217 |
def add_code_test(self, function, test_type='norm'): |
218 |
"Test part that examines the supplied code"
|
|
219 |
function = self._set_default_function(function, test_type) |
|
220 |
self._code_test = (test_type, function) |
|
221 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
222 |
def _check_output(self, solution_output, attempt_output, test_type, f): |
223 |
"""Compare solution output and attempt output using the
|
|
502
by stevenbird
www/apps/tutorialservice/__init__.py |
224 |
specified comparison function.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
225 |
"""
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
226 |
solution_output = str(solution_output) |
227 |
attempt_output = str(attempt_output) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
228 |
|
229 |
if test_type == 'norm': |
|
230 |
return f(solution_output) == f(attempt_output) |
|
231 |
else: |
|
232 |
return f(solution_output, attempt_output) |
|
233 |
||
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
234 |
def _check_code(self, solution, attempt, test_type, f, include_space): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
235 |
"""Compare solution code and attempt code using the
|
236 |
specified comparison function.
|
|
237 |
"""
|
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
238 |
if type(f) in types.StringTypes: # kludge |
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
239 |
f = eval(str(f), include_space) |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
240 |
if test_type == 'norm': |
241 |
return f(solution) == f(attempt) |
|
242 |
else: |
|
243 |
return f(solution, attempt) |
|
244 |
||
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
245 |
def run(self, solution_data, attempt_data, include_space): |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
246 |
"""Run the tests to compare the solution and attempt data
|
328
by mattgiuca
console: Renamed HTML element IDs to prefix "console_". |
247 |
Returns the empty string if the test passes, or else an error message.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
248 |
"""
|
249 |
||
502
by stevenbird
www/apps/tutorialservice/__init__.py |
250 |
# check source code itself
|
251 |
(test_type, f) = self._code_test |
|
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
252 |
if not self._check_code(solution_data['code'], attempt_data['code'], test_type, f, include_space): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
253 |
return 'Unexpected code' |
254 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
255 |
# check function return value (None for scripts)
|
256 |
(test_type, f) = self._result_test |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
257 |
if not self._check_output(solution_data['result'], attempt_data['result'], test_type, f): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
258 |
return 'Unexpected function return value' |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
259 |
|
260 |
# check stdout
|
|
261 |
(test_type, f) = self._stdout_test |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
262 |
if not self._check_output(solution_data['stdout'], attempt_data['stdout'], test_type, f): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
263 |
return 'Unexpected output' |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
264 |
|
265 |
#check stderr
|
|
266 |
(test_type, f) = self._stderr_test |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
267 |
if not self._check_output(solution_data['stderr'], attempt_data['stderr'], test_type, f): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
268 |
return 'Unexpected error output' |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
269 |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
270 |
#check exception
|
271 |
(test_type, f) = self._exception_test |
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
272 |
if not self._check_output(solution_data['exception'], attempt_data['exception'], test_type, f): |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
273 |
return 'Unexpected exception' |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
274 |
|
275 |
solution_files = solution_data['modified_files'] |
|
276 |
attempt_files = attempt_data['modified_files'] |
|
277 |
||
278 |
# check files indicated by test
|
|
279 |
for (filename, (test_type, f)) in self._file_tests.items(): |
|
280 |
if filename not in solution_files: |
|
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
281 |
raise FileNotFoundError(filename) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
282 |
elif filename not in attempt_files: |
283 |
return filename + ' not found' |
|
284 |
elif not self._check_output(solution_files[filename], attempt_files[filename], test_type, f): |
|
285 |
return filename + ' does not match' |
|
286 |
||
287 |
if self._default == 'ignore': |
|
288 |
return '' |
|
289 |
||
290 |
# check files found in solution, but not indicated by test
|
|
291 |
for filename in [f for f in solution_files if f not in self._file_tests]: |
|
292 |
if filename not in attempt_files: |
|
293 |
return filename + ' not found' |
|
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
294 |
elif not self._check_output(solution_files[filename], attempt_files[filename], 'match', self.match): |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
295 |
return filename + ' does not match' |
296 |
||
297 |
# check if attempt has any extra files
|
|
298 |
for filename in [f for f in attempt_files if f not in solution_files]: |
|
299 |
return "Unexpected file found: " + filename |
|
300 |
||
301 |
# Everything passed with no problems
|
|
302 |
return '' |
|
303 |
||
304 |
class TestCase: |
|
305 |
"""
|
|
306 |
A set of tests with a common inputs
|
|
307 |
"""
|
|
308 |
def __init__(self, name='', function=None, stdin='', filespace=None, global_space=None): |
|
309 |
"""Initialise with name and optionally, a function to test (instead of the entire script)
|
|
310 |
The inputs stdin, the filespace and global variables can also be specified at
|
|
311 |
initialisation, but may also be set later.
|
|
312 |
"""
|
|
313 |
if global_space == None: |
|
314 |
global_space = {} |
|
315 |
if filespace == None: |
|
316 |
filespace = {} |
|
317 |
||
318 |
self._name = name |
|
319 |
||
320 |
if function == '': function = None |
|
321 |
self._function = function |
|
322 |
self._list_args = [] |
|
323 |
self._keyword_args = {} |
|
324 |
||
325 |
# stdin must have a newline at the end for raw_input to work properly
|
|
326 |
if stdin[-1:] != '\n': stdin += '\n' |
|
327 |
||
328 |
self._stdin = stdin |
|
329 |
self._filespace = TestFilespace(filespace) |
|
330 |
self._global_space = global_space |
|
331 |
self._parts = [] |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
332 |
self._allowed_exceptions = set() |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
333 |
|
334 |
def set_stdin(self, stdin): |
|
335 |
""" Set the given string as the stdin for this test case"""
|
|
336 |
self._stdin = stdin |
|
337 |
||
338 |
def add_file(self, filename, data): |
|
339 |
""" Insert the given filename-data pair into the filespace for this test case"""
|
|
340 |
self._filespace.add_file(filename, data) |
|
341 |
||
342 |
def add_variable(self, variable, value): |
|
343 |
""" Add the given varibale-value pair to the initial global environment
|
|
344 |
for this test case.
|
|
507
by stevenbird
Extended code test framework to support tests on the code string, rather |
345 |
Throw and exception if the value cannot be paresed.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
346 |
"""
|
347 |
||
348 |
try: |
|
349 |
self._global_space[variable] = eval(value) |
|
350 |
except: |
|
351 |
raise TestCreationError("Invalid value for variable %s: %s" %(variable, value)) |
|
352 |
||
353 |
def add_arg(self, value, name=None): |
|
354 |
""" Add a value to the argument list. This only applies when testing functions.
|
|
355 |
By default arguments are not named, but if they are, they become keyword arguments.
|
|
356 |
"""
|
|
357 |
try: |
|
358 |
if name == None or name == '': |
|
359 |
self._list_args.append(eval(value)) |
|
360 |
else: |
|
361 |
self._keyword_args[name] = value |
|
362 |
except: |
|
363 |
raise TestCreationError("Invalid value for function argument: %s" %value) |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
364 |
|
365 |
def add_exception(self, exception_name): |
|
366 |
self._allowed_exceptions.add(exception_name) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
367 |
|
368 |
def add_part(self, test_part): |
|
369 |
""" Add a TestPart to this test case"""
|
|
370 |
self._parts.append(test_part) |
|
371 |
||
372 |
def validate_functions(self, included_code): |
|
373 |
""" Validate all the functions in each part in this test case
|
|
374 |
This can only be done once all the include code has been specified.
|
|
375 |
"""
|
|
376 |
for part in self._parts: |
|
377 |
part.validate_functions(included_code) |
|
378 |
||
379 |
def get_name(self): |
|
380 |
""" Get the name of the test case """
|
|
381 |
return self._name |
|
382 |
||
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
383 |
def run(self, solution, attempt_code, include_space, stop_on_fail=True): |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
384 |
""" Run the solution and the attempt with the inputs specified for this test case.
|
385 |
Then pass the outputs to each test part and collate the results.
|
|
386 |
"""
|
|
387 |
case_dict = {} |
|
388 |
case_dict['name'] = self._name |
|
389 |
||
390 |
# Run solution
|
|
391 |
try: |
|
392 |
global_space_copy = copy.deepcopy(self._global_space) |
|
393 |
solution_data = self._execstring(solution, global_space_copy) |
|
394 |
||
395 |
# if we are just testing a function
|
|
396 |
if not self._function == None: |
|
397 |
if self._function not in global_space_copy: |
|
398 |
raise FunctionNotFoundError(self._function) |
|
716
by mattgiuca
Test Framework: Numerous bug fixes. |
399 |
func_to_exec = lambda: global_space_copy[self._function]( |
400 |
*self._list_args, **self._keyword_args) |
|
401 |
solution_data = self._run_function(func_to_exec, solution) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
402 |
|
403 |
except: |
|
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
404 |
raise ScriptExecutionError(sys.exc_info()) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
405 |
|
406 |
# Run student attempt
|
|
407 |
try: |
|
408 |
global_space_copy = copy.deepcopy(self._global_space) |
|
302
by dilshan_a
Updated test framework so that student's code is passed in as a string. |
409 |
attempt_data = self._execstring(attempt_code, global_space_copy) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
410 |
|
411 |
# if we are just testing a function
|
|
412 |
if not self._function == None: |
|
413 |
if self._function not in global_space_copy: |
|
414 |
raise FunctionNotFoundError(self._function) |
|
716
by mattgiuca
Test Framework: Numerous bug fixes. |
415 |
func_to_exec = lambda: global_space_copy[self._function]( |
416 |
*self._list_args, **self._keyword_args) |
|
417 |
attempt_data = self._run_function(func_to_exec, attempt_code) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
418 |
except: |
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
419 |
case_dict['exception'] = ScriptExecutionError(sys.exc_info()).to_dict() |
304
by dilshan_a
Added documentation of output of TestSuite. |
420 |
case_dict['passed'] = False |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
421 |
return case_dict |
422 |
||
423 |
results = [] |
|
304
by dilshan_a
Added documentation of output of TestSuite. |
424 |
passed = True |
425 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
426 |
# generate results
|
427 |
for test_part in self._parts: |
|
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
428 |
try: |
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
429 |
result = test_part.run(solution_data, attempt_data, include_space) |
306
by dilshan_a
Consolidated SolutionError and AttemptError into ScriptExecutionError. |
430 |
except: |
431 |
raise TestError(sys.exc_info()) |
|
432 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
433 |
result_dict = {} |
502
by stevenbird
www/apps/tutorialservice/__init__.py |
434 |
result_dict['description'] = test_part._pass_msg |
495
by stevenbird
Fixes to permit content authors to produce nicer diagnostic responses as a result |
435 |
result_dict['passed'] = (result == '') |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
436 |
if result_dict['passed'] == False: |
437 |
result_dict['error_message'] = result |
|
502
by stevenbird
www/apps/tutorialservice/__init__.py |
438 |
result_dict['description'] = test_part._fail_msg |
304
by dilshan_a
Added documentation of output of TestSuite. |
439 |
passed = False |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
440 |
|
441 |
results.append(result_dict) |
|
442 |
||
514
by stevenbird
More flexible control of test_case_parts via optional flag |
443 |
# Do we continue the test_parts after one of them has failed?
|
444 |
if not passed and stop_on_fail: |
|
445 |
break; |
|
446 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
447 |
case_dict['parts'] = results |
304
by dilshan_a
Added documentation of output of TestSuite. |
448 |
case_dict['passed'] = passed |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
449 |
|
450 |
return case_dict |
|
451 |
||
452 |
def _execfile(self, filename, global_space): |
|
453 |
""" Execute the file given by 'filename' in global_space, and return the outputs. """
|
|
454 |
self._initialise_global_space(global_space) |
|
716
by mattgiuca
Test Framework: Numerous bug fixes. |
455 |
data = self._run_function(lambda: execfile(filename, global_space), |
456 |
code = open(filename).read()) |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
457 |
return data |
458 |
||
459 |
def _execstring(self, string, global_space): |
|
460 |
""" Execute the given string in global_space, and return the outputs. """
|
|
461 |
self._initialise_global_space(global_space) |
|
305
by dilshan_a
Neated up execution of strings in TestCase. |
462 |
|
463 |
def f(): |
|
464 |
exec string in global_space |
|
465 |
||
716
by mattgiuca
Test Framework: Numerous bug fixes. |
466 |
data = self._run_function(f, code=string) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
467 |
return data |
468 |
||
469 |
def _initialise_global_space(self, global_space): |
|
470 |
""" Modify the provided global_space so that file, open and raw_input are redefined
|
|
471 |
to use our methods instead.
|
|
472 |
"""
|
|
473 |
self._current_filespace_copy = self._filespace.copy() |
|
474 |
global_space['file'] = lambda filename, mode='r', bufsize=-1: self._current_filespace_copy.openfile(filename, mode) |
|
475 |
global_space['open'] = global_space['file'] |
|
476 |
global_space['raw_input'] = lambda x=None: raw_input() |
|
477 |
return global_space |
|
478 |
||
716
by mattgiuca
Test Framework: Numerous bug fixes. |
479 |
def _run_function(self, function, code): |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
480 |
""" Run the provided function with the provided stdin, capturing stdout and stderr
|
481 |
and the return value.
|
|
482 |
Return all the output data.
|
|
716
by mattgiuca
Test Framework: Numerous bug fixes. |
483 |
code: The full text of the code, which needs to be stored as part of
|
484 |
the returned dictionary.
|
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
485 |
"""
|
486 |
import sys, StringIO |
|
487 |
sys_stdout, sys_stdin, sys_stderr = sys.stdout, sys.stdin, sys.stderr |
|
488 |
||
489 |
output_stream, input_stream, error_stream = StringIO.StringIO(), StringIO.StringIO(self._stdin), StringIO.StringIO() |
|
490 |
sys.stdout, sys.stdin, sys.stderr = output_stream, input_stream, error_stream |
|
491 |
||
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
492 |
result = None |
493 |
exception_name = None |
|
494 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
495 |
try: |
305
by dilshan_a
Neated up execution of strings in TestCase. |
496 |
result = function() |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
497 |
except: |
498 |
sys.stdout, sys.stdin, sys.stderr = sys_stdout, sys_stdin, sys_stderr |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
499 |
exception_name = sys.exc_info()[0].__name__ |
500 |
if exception_name not in self._allowed_exceptions: |
|
501 |
raise
|
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
502 |
|
503 |
sys.stdout, sys.stdin, sys.stderr = sys_stdout, sys_stdin, sys_stderr |
|
504 |
||
505 |
self._current_filespace_copy.flush_all() |
|
506 |
||
716
by mattgiuca
Test Framework: Numerous bug fixes. |
507 |
return {'code': code, |
508 |
'result': result, |
|
299
by dilshan_a
Test framework now handles exceptions as valid outputs for scripts. |
509 |
'exception': exception_name, |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
510 |
'stdout': output_stream.getvalue(), |
511 |
'stderr': output_stream.getvalue(), |
|
512 |
'modified_files': self._current_filespace_copy.get_modified_files()} |
|
513 |
||
514 |
class TestSuite: |
|
515 |
"""
|
|
513
by stevenbird
test/test_framework/*, exercises/sample/* |
516 |
The complete collection of test cases for a given exercise
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
517 |
"""
|
518 |
def __init__(self, name, solution=None): |
|
513
by stevenbird
test/test_framework/*, exercises/sample/* |
519 |
"""Initialise with the name of the test suite (the exercise name) and the solution.
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
520 |
The solution may be specified later.
|
521 |
"""
|
|
522 |
self._solution = solution |
|
523 |
self._name = name |
|
524 |
self._tests = [] |
|
525 |
self.add_include_code("") |
|
526 |
||
527 |
def add_solution(self, solution): |
|
513
by stevenbird
test/test_framework/*, exercises/sample/* |
528 |
" Specify the solution script for this exercise "
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
529 |
self._solution = solution |
530 |
||
531 |
def has_solution(self): |
|
523
by stevenbird
Adding ReStructured Text preprocessing of exercise descriptions, |
532 |
" Returns true if a solution has been provided "
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
533 |
return self._solution != None |
534 |
||
535 |
def add_include_code(self, include_code = ''): |
|
536 |
""" Add include code that may be used by the test cases during
|
|
537 |
comparison of outputs.
|
|
538 |
"""
|
|
539 |
||
540 |
# if empty, make sure it can still be executed
|
|
541 |
if include_code == "": |
|
542 |
include_code = "pass" |
|
543 |
self._include_code = str(include_code) |
|
544 |
||
545 |
include_space = {} |
|
546 |
try: |
|
547 |
exec self._include_code in include_space |
|
548 |
except: |
|
549 |
raise TestCreationError("Bad include code") |
|
550 |
||
551 |
self._include_space = include_space |
|
552 |
||
553 |
def add_case(self, test_case): |
|
554 |
""" Add a TestCase, then validate all functions inside test case
|
|
555 |
now that the include code is known
|
|
556 |
"""
|
|
557 |
self._tests.append(test_case) |
|
558 |
test_case.validate_functions(self._include_space) |
|
559 |
||
502
by stevenbird
www/apps/tutorialservice/__init__.py |
560 |
def run_tests(self, attempt_code, stop_on_fail=False): |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
561 |
" Run all test cases and collate the results "
|
562 |
||
513
by stevenbird
test/test_framework/*, exercises/sample/* |
563 |
exercise_dict = {} |
564 |
exercise_dict['name'] = self._name |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
565 |
|
566 |
test_case_results = [] |
|
309
by dilshan_a
Added a passed key to return value of problem suite. |
567 |
passed = True |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
568 |
for test in self._tests: |
519
by stevenbird
Fixed bug in test framework. Code given in the <include> section was |
569 |
result_dict = test.run(self._solution, attempt_code, self._include_space) |
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
570 |
if 'exception' in result_dict and result_dict['exception']['critical']: |
571 |
# critical error occured, running more cases is useless
|
|
572 |
# FunctionNotFound, Syntax, Indentation
|
|
513
by stevenbird
test/test_framework/*, exercises/sample/* |
573 |
exercise_dict['critical_error'] = result_dict['exception'] |
574 |
exercise_dict['passed'] = False |
|
575 |
return exercise_dict |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
576 |
|
577 |
test_case_results.append(result_dict) |
|
304
by dilshan_a
Added documentation of output of TestSuite. |
578 |
|
309
by dilshan_a
Added a passed key to return value of problem suite. |
579 |
if not result_dict['passed']: |
580 |
passed = False |
|
581 |
if stop_on_fail: |
|
582 |
break
|
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
583 |
|
513
by stevenbird
test/test_framework/*, exercises/sample/* |
584 |
exercise_dict['cases'] = test_case_results |
585 |
exercise_dict['passed'] = passed |
|
586 |
return exercise_dict |
|
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
587 |
|
588 |
def get_name(self): |
|
589 |
return self._name |
|
590 |
||
591 |
class TestFilespace: |
|
592 |
"""
|
|
593 |
Our dummy file system which is accessed by code being tested.
|
|
594 |
Implemented as a dictionary which maps filenames to strings
|
|
595 |
"""
|
|
596 |
def __init__(self, files=None): |
|
597 |
"Initialise, optionally with filename-filedata pairs"
|
|
598 |
||
599 |
if files == None: |
|
600 |
files = {} |
|
601 |
||
602 |
# dict mapping files to strings
|
|
603 |
self._files = {} |
|
604 |
self._files.update(files) |
|
605 |
# set of file names
|
|
606 |
self._modified_files = set([]) |
|
607 |
# dict mapping files to stringIO objects
|
|
608 |
self._open_files = {} |
|
609 |
||
610 |
def add_file(self, filename, data): |
|
611 |
" Add a file to the filespace "
|
|
612 |
self._files[filename] = data |
|
613 |
||
614 |
def openfile(self, filename, mode='r'): |
|
615 |
""" Open a file from the filespace with the given mode.
|
|
616 |
Return a StringIO subclass object with the file contents.
|
|
617 |
"""
|
|
309
by dilshan_a
Added a passed key to return value of problem suite. |
618 |
# currently very messy, needs to be cleaned up
|
619 |
# Probably most of this should be in the initialiser to the TestStringIO
|
|
620 |
||
294
by mattgiuca
Added application: tutorialservice. Will be used as the Ajax backend for |
621 |
import re |
622 |
||
623 |
if filename in self._open_files: |
|
624 |
raise IOError("File already open: %s" %filename) |
|
625 |
||
626 |
if not re.compile("[rwa][+b]{0,2}").match(mode): |
|
627 |
raise IOError("invalid mode %s" %mode) |
|
628 |
||
629 |
## TODO: validate filename?
|
|
630 |
||
631 |
mode.replace("b",'') |
|
632 |
||
633 |
# initialise the file properly (truncate/create if required)
|
|
634 |
if mode[0] == 'w': |
|
635 |
self._files[filename] = '' |
|
636 |
self._modified_files.add(filename) |
|
637 |
elif filename not in self._files: |
|
638 |
if mode[0] == 'a': |
|
639 |
self._files[filename] = '' |
|
640 |
self._modified_files.add(filename) |
|
641 |
else: |
|
642 |
raise IOError(2, "Access to file denied: %s" %filename) |
|
643 |
||
644 |
# for append mode, remember the existing data
|
|
645 |
if mode[0] == 'a': |
|
646 |
existing_data = self._files[filename] |
|
647 |
else: |
|
648 |
existing_data = "" |
|
649 |
||
650 |
# determine what operations are allowed
|
|
651 |
reading_ok = (len(mode) == 2 or mode[0] == 'r') |
|
652 |
writing_ok = (len(mode) == 2 or mode[0] in 'wa') |
|
653 |
||
654 |
# for all writing modes, start off with blank file
|
|
655 |
if mode[0] == 'w': |
|
656 |
initial_data = '' |
|
657 |
else: |
|
658 |
initial_data = self._files[filename] |
|
659 |
||
660 |
file_object = TestStringIO(initial_data, filename, self, reading_ok, writing_ok, existing_data) |
|
661 |
self._open_files[filename] = file_object |
|
662 |
||
663 |
return file_object |
|
664 |
||
665 |
def flush_all(self): |
|
666 |
""" Flush all open files
|
|
667 |
"""
|
|
668 |
for file_object in self._open_files.values(): |
|
669 |
file_object.flush() |
|
670 |
||
671 |
def updatefile(self,filename, data): |
|
672 |
""" Callback function used by an open file to inform when it has been updated.
|
|
673 |
"""
|
|
674 |
if filename in self._open_files: |
|
675 |
self._files[filename] = data |
|
676 |
if self._open_files[filename].is_modified(): |
|
677 |
self._modified_files.add(filename) |
|
678 |
else: |
|
679 |
raise IOError(2, "Access to file denied: %s" %filename) |
|
680 |
||
681 |
def closefile(self, filename): |
|
682 |
""" Callback function used by an open file to inform when it has been closed.
|
|
683 |
"""
|
|
684 |
if filename in self._open_files: |
|
685 |
del self._open_files[filename] |
|
686 |
||
687 |
def get_modified_files(self): |
|
688 |
"""" A subset of the filespace containing only those files which have been
|
|
689 |
modified
|
|
690 |
"""
|
|
691 |
modified_files = {} |
|
692 |
for filename in self._modified_files: |
|
693 |
modified_files[filename] = self._files[filename] |
|
694 |
||
695 |
return modified_files |
|
696 |
||
697 |
def get_open_files(self): |
|
698 |
" Return the names of all open files "
|
|
699 |
return self._open_files.keys() |
|
700 |
||
701 |
def copy(self): |
|
702 |
""" Return a copy of the current filespace.
|
|
703 |
Only the files are copied, not the modified or open file lists.
|
|
704 |
"""
|
|
705 |
self.flush_all() |
|
706 |
return TestFilespace(self._files) |
|
707 |
||
708 |
class TestStringIO(StringIO.StringIO): |
|
709 |
"""
|
|
710 |
A subclass of StringIO which acts as a file in our dummy file system
|
|
711 |
"""
|
|
712 |
def __init__(self, string, filename, filespace, reading_ok, writing_ok, existing_data): |
|
713 |
""" Initialise with the filedata, file name and infomation on what ops are
|
|
714 |
acceptable """
|
|
715 |
StringIO.StringIO.__init__(self, string) |
|
716 |
self._filename = filename |
|
717 |
self._filespace = filespace |
|
718 |
self._reading_ok = reading_ok |
|
719 |
self._writing_ok = writing_ok |
|
720 |
self._existing_data = existing_data |
|
721 |
self._modified = False |
|
722 |
self._open = True |
|
723 |
||
724 |
# Override all standard file ops. Make sure that they are valid with the given
|
|
725 |
# permissions and if so then call the corresponding method in StringIO
|
|
726 |
||
727 |
def read(self, *args): |
|
728 |
if not self._reading_ok: |
|
729 |
raise IOError(9, "Bad file descriptor") |
|
730 |
else: |
|
731 |
return StringIO.StringIO.read(self, *args) |
|
732 |
||
733 |
def readline(self, *args): |
|
734 |
if not self._reading_ok: |
|
735 |
raise IOError(9, "Bad file descriptor") |
|
736 |
else: |
|
737 |
return StringIO.StringIO.readline(self, *args) |
|
738 |
||
739 |
def readlines(self, *args): |
|
740 |
if not self._reading_ok: |
|
741 |
raise IOError(9, "Bad file descriptor") |
|
742 |
else: |
|
743 |
return StringIO.StringIO.readlines(self, *args) |
|
744 |
||
745 |
def seek(self, *args): |
|
746 |
if not self._reading_ok: |
|
747 |
raise IOError(9, "Bad file descriptor") |
|
748 |
else: |
|
749 |
return StringIO.StringIO.seek(self, *args) |
|
750 |
||
751 |
def truncate(self, *args): |
|
752 |
self._modified = True |
|
753 |
if not self._writing_ok: |
|
754 |
raise IOError(9, "Bad file descriptor") |
|
755 |
else: |
|
756 |
return StringIO.StringIO.truncate(self, *args) |
|
757 |
||
758 |
def write(self, *args): |
|
759 |
self._modified = True |
|
760 |
if not self._writing_ok: |
|
761 |
raise IOError(9, "Bad file descriptor") |
|
762 |
else: |
|
763 |
return StringIO.StringIO.write(self, *args) |
|
764 |
||
765 |
def writelines(self, *args): |
|
766 |
self._modified = True |
|
767 |
if not self._writing_ok: |
|
768 |
raise IOError(9, "Bad file descriptor") |
|
769 |
else: |
|
770 |
return StringIO.StringIO.writelines(self, *args) |
|
771 |
||
772 |
def is_modified(self): |
|
773 |
" Return true if the file has been written to, or truncated"
|
|
774 |
return self._modified |
|
775 |
||
776 |
def flush(self): |
|
777 |
" Update the contents of the filespace with the new data "
|
|
778 |
self._filespace.updatefile(self._filename, self._existing_data+self.getvalue()) |
|
779 |
return StringIO.StringIO.flush(self) |
|
780 |
||
781 |
def close(self): |
|
782 |
" Flush the file and close it "
|
|
783 |
self.flush() |
|
784 |
self._filespace.closefile(self._filename) |
|
785 |
return StringIO.StringIO.close(self) |
|
786 |
||
787 |
##def get_function(filename, function_name):
|
|
788 |
## import compiler
|
|
789 |
## mod = compiler.parseFile(filename)
|
|
790 |
## for node in mod.node.nodes:
|
|
791 |
## if isinstance(node, compiler.ast.Function) and node.name == function_name:
|
|
792 |
## return node
|
|
793 |
##
|