pl-e2e-test 0.1.7__tar.gz → 0.1.8__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.
- {pl_e2e_test-0.1.7/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.8}/PKG-INFO +8 -7
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/README.md +7 -6
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/pyproject.toml +1 -1
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8/src/pl_e2e_test.egg-info}/PKG-INFO +8 -7
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/discovery.py +3 -17
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/harness.py +1 -1
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_discovery.py +28 -18
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_harness.py +50 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_question_tests.py +96 -2
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/LICENSE +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/setup.cfg +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__init__.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/expected_errors.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/question_tests.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/reporting.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_config.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_reporting.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_server.py +0 -0
- {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_sharding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -303,7 +303,7 @@ does not provide one itself.
|
|
|
303
303
|
For every gradable `answers-name`, the correct test case must provide:
|
|
304
304
|
|
|
305
305
|
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
306
|
-
more compound input keys prefixed with `answers-name
|
|
306
|
+
more compound input keys prefixed with `answers-name-` or `answers-name.`; and
|
|
307
307
|
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
308
308
|
|
|
309
309
|
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
@@ -332,11 +332,12 @@ def test(data):
|
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
Compound elements keep the declared answer name as their grading identity while using
|
|
335
|
-
namespaced child fields for browser inputs.
|
|
336
|
-
`answers-name="match"` may test and parse
|
|
337
|
-
|
|
338
|
-
`
|
|
339
|
-
|
|
335
|
+
namespaced child fields for browser inputs. Hyphen and dot namespace boundaries are
|
|
336
|
+
supported. For example, an element with `answers-name="match"` may test and parse
|
|
337
|
+
`match-dropdown-0` and `match-dropdown-1`, while `answers-name="average"` may use
|
|
338
|
+
`average.main`. The element stores its score under the declared answer name and does not
|
|
339
|
+
need to invent a direct raw value for it. After parsing, every raw field associated with
|
|
340
|
+
a declared gradable answer must either remain non-null under its child key or produce a
|
|
340
341
|
non-null value under its declared parent answer. This also supports elements that
|
|
341
342
|
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
342
343
|
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
@@ -279,7 +279,7 @@ does not provide one itself.
|
|
|
279
279
|
For every gradable `answers-name`, the correct test case must provide:
|
|
280
280
|
|
|
281
281
|
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
282
|
-
more compound input keys prefixed with `answers-name
|
|
282
|
+
more compound input keys prefixed with `answers-name-` or `answers-name.`; and
|
|
283
283
|
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
284
284
|
|
|
285
285
|
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
@@ -308,11 +308,12 @@ def test(data):
|
|
|
308
308
|
```
|
|
309
309
|
|
|
310
310
|
Compound elements keep the declared answer name as their grading identity while using
|
|
311
|
-
namespaced child fields for browser inputs.
|
|
312
|
-
`answers-name="match"` may test and parse
|
|
313
|
-
|
|
314
|
-
`
|
|
315
|
-
|
|
311
|
+
namespaced child fields for browser inputs. Hyphen and dot namespace boundaries are
|
|
312
|
+
supported. For example, an element with `answers-name="match"` may test and parse
|
|
313
|
+
`match-dropdown-0` and `match-dropdown-1`, while `answers-name="average"` may use
|
|
314
|
+
`average.main`. The element stores its score under the declared answer name and does not
|
|
315
|
+
need to invent a direct raw value for it. After parsing, every raw field associated with
|
|
316
|
+
a declared gradable answer must either remain non-null under its child key or produce a
|
|
316
317
|
non-null value under its declared parent answer. This also supports elements that
|
|
317
318
|
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
318
319
|
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -303,7 +303,7 @@ does not provide one itself.
|
|
|
303
303
|
For every gradable `answers-name`, the correct test case must provide:
|
|
304
304
|
|
|
305
305
|
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
306
|
-
more compound input keys prefixed with `answers-name
|
|
306
|
+
more compound input keys prefixed with `answers-name-` or `answers-name.`; and
|
|
307
307
|
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
308
308
|
|
|
309
309
|
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
@@ -332,11 +332,12 @@ def test(data):
|
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
Compound elements keep the declared answer name as their grading identity while using
|
|
335
|
-
namespaced child fields for browser inputs.
|
|
336
|
-
`answers-name="match"` may test and parse
|
|
337
|
-
|
|
338
|
-
`
|
|
339
|
-
|
|
335
|
+
namespaced child fields for browser inputs. Hyphen and dot namespace boundaries are
|
|
336
|
+
supported. For example, an element with `answers-name="match"` may test and parse
|
|
337
|
+
`match-dropdown-0` and `match-dropdown-1`, while `answers-name="average"` may use
|
|
338
|
+
`average.main`. The element stores its score under the declared answer name and does not
|
|
339
|
+
need to invent a direct raw value for it. After parsing, every raw field associated with
|
|
340
|
+
a declared gradable answer must either remain non-null under its child key or produce a
|
|
340
341
|
non-null value under its declared parent answer. This also supports elements that
|
|
341
342
|
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
342
343
|
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
@@ -234,23 +234,9 @@ class QuestionCase:
|
|
|
234
234
|
return value if isinstance(value, dict) else {}
|
|
235
235
|
|
|
236
236
|
def is_manual_grading(self) -> bool:
|
|
237
|
-
"""Whether
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
declared = metadata.get("gradingMethod") == "Manual"
|
|
241
|
-
question_path = self.directory / "question.html"
|
|
242
|
-
if not question_path.is_file():
|
|
243
|
-
return declared
|
|
244
|
-
has_manual_content = (
|
|
245
|
-
"<pl-manual-grading-only"
|
|
246
|
-
in question_path.read_text(encoding="utf-8").lower()
|
|
247
|
-
)
|
|
248
|
-
if has_manual_content and not declared:
|
|
249
|
-
raise HarnessError(
|
|
250
|
-
f"{self.info_path}: question uses pl-manual-grading-only "
|
|
251
|
-
'but info.json does not declare gradingMethod: "Manual"'
|
|
252
|
-
)
|
|
253
|
-
return declared or has_manual_content
|
|
237
|
+
"""Whether question metadata configures manual-only grading."""
|
|
238
|
+
|
|
239
|
+
return self.unvalidated_metadata().get("gradingMethod") == "Manual"
|
|
254
240
|
|
|
255
241
|
|
|
256
242
|
def variant_seeds(
|
|
@@ -75,7 +75,7 @@ def _associated_answer_name(raw_name: str, answer_names: tuple[str, ...]) -> str
|
|
|
75
75
|
(
|
|
76
76
|
answer_name
|
|
77
77
|
for answer_name in answer_names
|
|
78
|
-
if raw_name.startswith(f"{answer_name}-")
|
|
78
|
+
if raw_name.startswith((f"{answer_name}-", f"{answer_name}."))
|
|
79
79
|
),
|
|
80
80
|
key=len,
|
|
81
81
|
default=None,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -269,28 +270,37 @@ def test_question_local_e2e_config_is_strict(
|
|
|
269
270
|
load_e2e_config(config_path)
|
|
270
271
|
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
@pytest.mark.parametrize(
|
|
274
|
+
("grading_method", "has_manual_content", "expected"),
|
|
275
|
+
[
|
|
276
|
+
(None, False, False),
|
|
277
|
+
(None, True, False),
|
|
278
|
+
("Internal", True, False),
|
|
279
|
+
("External", True, False),
|
|
280
|
+
("Manual", False, True),
|
|
281
|
+
("Manual", True, True),
|
|
282
|
+
],
|
|
283
|
+
)
|
|
284
|
+
def test_manual_only_classification_uses_metadata(
|
|
285
|
+
tmp_path: Path,
|
|
286
|
+
grading_method: str | None,
|
|
287
|
+
has_manual_content: bool,
|
|
288
|
+
expected: bool,
|
|
289
|
+
) -> None:
|
|
273
290
|
question = tmp_path / "question"
|
|
274
291
|
question.mkdir()
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
(question / "
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
metadata = {"type": "v3"}
|
|
293
|
+
if grading_method is not None:
|
|
294
|
+
metadata["gradingMethod"] = grading_method
|
|
295
|
+
(question / "info.json").write_text(json.dumps(metadata), encoding="utf-8")
|
|
296
|
+
manual_content = (
|
|
297
|
+
"<pl-manual-grading-only>guidance</pl-manual-grading-only>"
|
|
298
|
+
if has_manual_content
|
|
299
|
+
else ""
|
|
281
300
|
)
|
|
282
|
-
|
|
283
|
-
assert QuestionCase(question, tmp_path).is_manual_grading()
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def test_manual_grading_marker_requires_metadata(tmp_path: Path) -> None:
|
|
287
|
-
question = tmp_path / "question"
|
|
288
|
-
question.mkdir()
|
|
289
|
-
(question / "info.json").write_text('{"type": "v3"}', encoding="utf-8")
|
|
290
301
|
(question / "question.html").write_text(
|
|
291
|
-
"<
|
|
302
|
+
f"<p>Question</p>{manual_content}",
|
|
292
303
|
encoding="utf-8",
|
|
293
304
|
)
|
|
294
305
|
|
|
295
|
-
|
|
296
|
-
QuestionCase(question, tmp_path).is_manual_grading()
|
|
306
|
+
assert QuestionCase(question, tmp_path).is_manual_grading() is expected
|
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
from prairielearn_e2e import HarnessConfig, PrairieLearnBackend, QuestionHarness
|
|
9
9
|
from prairielearn_e2e.contract import Phase
|
|
10
10
|
from prairielearn_e2e.errors import HarnessError
|
|
11
|
+
from prairielearn_e2e.harness import _associated_answer_name
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class RecordingBackend:
|
|
@@ -96,6 +97,20 @@ class ParentNormalizedAnswerBackend(RecordingBackend):
|
|
|
96
97
|
return result
|
|
97
98
|
|
|
98
99
|
|
|
100
|
+
class DotNormalizedAnswerBackend(RecordingBackend):
|
|
101
|
+
def process(
|
|
102
|
+
self, phase: Phase, html: str, data: dict[str, Any]
|
|
103
|
+
) -> tuple[str | None, set[str]]:
|
|
104
|
+
result = super().process(phase, html, data)
|
|
105
|
+
if phase == "test":
|
|
106
|
+
data["raw_submitted_answers"]["average.main"] = '{"solution":[]}'
|
|
107
|
+
data["partial_scores"]["average"] = {"score": 1}
|
|
108
|
+
elif phase == "parse":
|
|
109
|
+
data["submitted_answers"].pop("average.main")
|
|
110
|
+
data["submitted_answers"]["average"] = "def average(values): ..."
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
99
114
|
def _harness(tmp_path: Path) -> tuple[QuestionHarness, RecordingBackend]:
|
|
100
115
|
schemas = tmp_path / ".prairielearn" / "schemas"
|
|
101
116
|
schemas.mkdir(parents=True)
|
|
@@ -382,3 +397,38 @@ def test_namespaced_raw_value_can_parse_into_declared_parent(tmp_path: Path) ->
|
|
|
382
397
|
|
|
383
398
|
assert result.raw_submitted_answers == {"ordering-input": '[{"id":"a"}]'}
|
|
384
399
|
assert result.submitted_answers == {"ordering": [{"id": "a"}]}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_dot_namespaced_raw_value_can_parse_into_declared_parent(
|
|
403
|
+
tmp_path: Path,
|
|
404
|
+
) -> None:
|
|
405
|
+
harness = _answer_harness(
|
|
406
|
+
tmp_path,
|
|
407
|
+
DotNormalizedAnswerBackend(),
|
|
408
|
+
html='<pl-faded-parsons answers-name="average"></pl-faded-parsons>',
|
|
409
|
+
selector="pl-faded-parsons",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
result = harness.run_grade(0)
|
|
413
|
+
|
|
414
|
+
assert result.raw_submitted_answers == {"average.main": '{"solution":[]}'}
|
|
415
|
+
assert "average" not in result.raw_submitted_answers
|
|
416
|
+
assert result.submitted_answers == {"average": "def average(values): ..."}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@pytest.mark.parametrize(
|
|
420
|
+
("raw_name", "answer_names", "expected"),
|
|
421
|
+
[
|
|
422
|
+
("average", ("average",), "average"),
|
|
423
|
+
("match-dropdown-0", ("match",), "match"),
|
|
424
|
+
("average.main", ("average",), "average"),
|
|
425
|
+
("average.main", ("average", "average.main"), "average.main"),
|
|
426
|
+
("average.main-value", ("average", "average.main"), "average.main"),
|
|
427
|
+
("average_main", ("average",), None),
|
|
428
|
+
("averageish", ("average",), None),
|
|
429
|
+
],
|
|
430
|
+
)
|
|
431
|
+
def test_raw_answer_association_uses_supported_namespace_boundaries(
|
|
432
|
+
raw_name: str, answer_names: tuple[str, ...], expected: str | None
|
|
433
|
+
) -> None:
|
|
434
|
+
assert _associated_answer_name(raw_name, answer_names) == expected
|
|
@@ -7,17 +7,21 @@ from typing import Any, cast
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from prairielearn_e2e.config import DEFAULT_CONFIG
|
|
10
|
+
from prairielearn_e2e.contract import Phase
|
|
10
11
|
from prairielearn_e2e.discovery import QuestionCase
|
|
11
12
|
from prairielearn_e2e.errors import HarnessError
|
|
12
13
|
from prairielearn_e2e.expected_errors import ExpectedErrorAudit
|
|
13
14
|
from prairielearn_e2e.question_tests import _run_check
|
|
15
|
+
from prairielearn_e2e.question_tests import test_question_grade as run_question_grade
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class FakeRequest:
|
|
17
|
-
def __init__(
|
|
19
|
+
def __init__(
|
|
20
|
+
self, audit: ExpectedErrorAudit, backend: object | None = None
|
|
21
|
+
) -> None:
|
|
18
22
|
self.fixtures: dict[str, Any] = {
|
|
19
23
|
"expected_error_audit": audit,
|
|
20
|
-
"prairielearn_backend": object(),
|
|
24
|
+
"prairielearn_backend": backend if backend is not None else object(),
|
|
21
25
|
"harness_config": DEFAULT_CONFIG,
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -25,6 +29,23 @@ class FakeRequest:
|
|
|
25
29
|
return self.fixtures[name]
|
|
26
30
|
|
|
27
31
|
|
|
32
|
+
class LifecycleBackend:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.phases: list[Phase] = []
|
|
35
|
+
self.grade_score: object | None = None
|
|
36
|
+
|
|
37
|
+
def process(
|
|
38
|
+
self, phase: Phase, html: str, data: dict[str, Any]
|
|
39
|
+
) -> tuple[str | None, set[str]]:
|
|
40
|
+
self.phases.append(phase)
|
|
41
|
+
if phase == "grade":
|
|
42
|
+
self.grade_score = data["score"]
|
|
43
|
+
return (html if phase == "render" else None), set()
|
|
44
|
+
|
|
45
|
+
def unresolved_elements(self, _html: str) -> set[str]:
|
|
46
|
+
return set()
|
|
47
|
+
|
|
48
|
+
|
|
28
49
|
def _question(tmp_path: Path, rules: list[dict[str, str]]) -> QuestionCase:
|
|
29
50
|
directory = tmp_path / "questions" / "example"
|
|
30
51
|
directory.mkdir(parents=True)
|
|
@@ -35,6 +56,44 @@ def _question(tmp_path: Path, rules: list[dict[str, str]]) -> QuestionCase:
|
|
|
35
56
|
return QuestionCase(directory, tmp_path)
|
|
36
57
|
|
|
37
58
|
|
|
59
|
+
def _lifecycle_question(tmp_path: Path, grading_method: str) -> QuestionCase:
|
|
60
|
+
schemas = tmp_path / ".prairielearn" / "schemas"
|
|
61
|
+
schemas.mkdir(parents=True)
|
|
62
|
+
(schemas / "infoQuestion.json").write_text("{}", encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
directory = tmp_path / "questions" / "hybrid"
|
|
65
|
+
directory.mkdir(parents=True)
|
|
66
|
+
(directory / "info.json").write_text(
|
|
67
|
+
json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"type": "v3",
|
|
70
|
+
"gradingMethod": grading_method,
|
|
71
|
+
"singleVariant": True,
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
(directory / "question.html").write_text(
|
|
77
|
+
'<pl-number-input answers-name="answer"></pl-number-input>'
|
|
78
|
+
"<pl-question-panel>"
|
|
79
|
+
"<pl-manual-grading-only>"
|
|
80
|
+
"<p>Rubric for the manually graded explanation.</p>"
|
|
81
|
+
"</pl-manual-grading-only>"
|
|
82
|
+
"</pl-question-panel>",
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
)
|
|
85
|
+
(directory / "server.py").write_text(
|
|
86
|
+
"def generate(data):\n"
|
|
87
|
+
" data['correct_answers']['answer'] = 1\n"
|
|
88
|
+
"\n"
|
|
89
|
+
"def test(data):\n"
|
|
90
|
+
" data['raw_submitted_answers']['answer'] = '1'\n"
|
|
91
|
+
" data['partial_scores']['answer'] = {'score': 1}\n",
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
)
|
|
94
|
+
return QuestionCase(directory, tmp_path)
|
|
95
|
+
|
|
96
|
+
|
|
38
97
|
def _rule(
|
|
39
98
|
*,
|
|
40
99
|
exception_type: str | None = None,
|
|
@@ -128,3 +187,38 @@ def test_nonmatching_error_is_not_hidden(
|
|
|
128
187
|
"grade",
|
|
129
188
|
fail,
|
|
130
189
|
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_hybrid_question_runs_automatic_grade_lifecycle(tmp_path: Path) -> None:
|
|
193
|
+
question = _lifecycle_question(tmp_path, "Internal")
|
|
194
|
+
backend = LifecycleBackend()
|
|
195
|
+
|
|
196
|
+
run_question_grade(
|
|
197
|
+
(question, 0),
|
|
198
|
+
cast(
|
|
199
|
+
pytest.FixtureRequest,
|
|
200
|
+
FakeRequest(ExpectedErrorAudit(), backend),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
assert "grade" in backend.phases
|
|
205
|
+
assert backend.phases[-1] == "file"
|
|
206
|
+
assert backend.grade_score == 1
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_manual_only_question_skips_grade_lifecycle(tmp_path: Path) -> None:
|
|
210
|
+
question = _lifecycle_question(tmp_path, "Manual")
|
|
211
|
+
backend = LifecycleBackend()
|
|
212
|
+
|
|
213
|
+
with pytest.raises(
|
|
214
|
+
pytest.skip.Exception, match="question is configured for manual grading"
|
|
215
|
+
):
|
|
216
|
+
run_question_grade(
|
|
217
|
+
(question, 0),
|
|
218
|
+
cast(
|
|
219
|
+
pytest.FixtureRequest,
|
|
220
|
+
FakeRequest(ExpectedErrorAudit(), backend),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
assert backend.phases == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|