pl-e2e-test 0.1.6__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.6/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.8}/PKG-INFO +14 -9
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/README.md +13 -8
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/pyproject.toml +1 -1
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8/src/pl_e2e_test.egg-info}/PKG-INFO +14 -9
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/discovery.py +3 -17
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/harness.py +7 -7
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_discovery.py +28 -18
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_harness.py +113 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_publish_version.py +35 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_question_tests.py +96 -2
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/LICENSE +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/setup.cfg +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__init__.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/expected_errors.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/question_tests.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/reporting.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_config.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_reporting.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_server.py +0 -0
- {pl_e2e_test-0.1.6 → 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,12 +332,16 @@ 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
|
-
|
|
340
|
-
|
|
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
|
|
341
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
342
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
343
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
344
|
+
parse-coverage obligations.
|
|
341
345
|
|
|
342
346
|
This contract keeps element-specific synthesis with the element or question that owns
|
|
343
347
|
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
@@ -480,7 +484,8 @@ environment. From a clean branch, run:
|
|
|
480
484
|
make publish-version VERSION=0.2.0
|
|
481
485
|
```
|
|
482
486
|
|
|
483
|
-
This
|
|
487
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
488
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
484
489
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
485
490
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
486
491
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -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,12 +308,16 @@ 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
|
-
|
|
316
|
-
|
|
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
|
|
317
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
318
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
319
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
320
|
+
parse-coverage obligations.
|
|
317
321
|
|
|
318
322
|
This contract keeps element-specific synthesis with the element or question that owns
|
|
319
323
|
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
@@ -456,7 +460,8 @@ environment. From a clean branch, run:
|
|
|
456
460
|
make publish-version VERSION=0.2.0
|
|
457
461
|
```
|
|
458
462
|
|
|
459
|
-
This
|
|
463
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
464
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
460
465
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
461
466
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
462
467
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -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,12 +332,16 @@ 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
|
-
|
|
340
|
-
|
|
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
|
|
341
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
342
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
343
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
344
|
+
parse-coverage obligations.
|
|
341
345
|
|
|
342
346
|
This contract keeps element-specific synthesis with the element or question that owns
|
|
343
347
|
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
@@ -480,7 +484,8 @@ environment. From a clean branch, run:
|
|
|
480
484
|
make publish-version VERSION=0.2.0
|
|
481
485
|
```
|
|
482
486
|
|
|
483
|
-
This
|
|
487
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
488
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
484
489
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
485
490
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
486
491
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -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,
|
|
@@ -128,13 +128,13 @@ def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) ->
|
|
|
128
128
|
submitted_answers = data["submitted_answers"]
|
|
129
129
|
unparsed: list[str] = []
|
|
130
130
|
for raw_name in data["raw_submitted_answers"]:
|
|
131
|
-
if submitted_answers.get(raw_name) is not None:
|
|
132
|
-
continue
|
|
133
131
|
parent_name = _associated_answer_name(raw_name, answer_names)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
if parent_name is None:
|
|
133
|
+
continue
|
|
134
|
+
if (
|
|
135
|
+
submitted_answers.get(raw_name) is None
|
|
136
|
+
and submitted_answers.get(parent_name) is None
|
|
137
|
+
):
|
|
138
138
|
unparsed.append(raw_name)
|
|
139
139
|
if unparsed:
|
|
140
140
|
raise HarnessError(
|
|
@@ -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:
|
|
@@ -25,6 +26,10 @@ class RecordingBackend:
|
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class ElementOwnedAnswerBackend(RecordingBackend):
|
|
29
|
+
def __init__(self, *, null_after_parse: bool = False) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.null_after_parse = null_after_parse
|
|
32
|
+
|
|
28
33
|
def process(
|
|
29
34
|
self, phase: Phase, html: str, data: dict[str, Any]
|
|
30
35
|
) -> tuple[str | None, set[str]]:
|
|
@@ -32,6 +37,27 @@ class ElementOwnedAnswerBackend(RecordingBackend):
|
|
|
32
37
|
if phase == "test":
|
|
33
38
|
data["raw_submitted_answers"]["diagram"] = '{"nodes":[1,2]}'
|
|
34
39
|
data["partial_scores"]["diagram"] = {"score": 1}
|
|
40
|
+
elif phase == "parse" and self.null_after_parse:
|
|
41
|
+
data["submitted_answers"]["diagram"] = None
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnassociatedFileFieldBackend(RecordingBackend):
|
|
46
|
+
def process(
|
|
47
|
+
self, phase: Phase, html: str, data: dict[str, Any]
|
|
48
|
+
) -> tuple[str | None, set[str]]:
|
|
49
|
+
result = super().process(phase, html, data)
|
|
50
|
+
if phase == "test":
|
|
51
|
+
data["raw_submitted_answers"].update(
|
|
52
|
+
{
|
|
53
|
+
"diagram": '{"nodes":[1,2]}',
|
|
54
|
+
"_file_transport_token": '[{"name":"notes.txt"}]',
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
data["partial_scores"]["diagram"] = {"score": 1}
|
|
58
|
+
elif phase == "parse":
|
|
59
|
+
data["submitted_answers"].pop("_file_transport_token")
|
|
60
|
+
data["submitted_answers"]["_files"] = [{"name": "notes.txt"}]
|
|
35
61
|
return result
|
|
36
62
|
|
|
37
63
|
|
|
@@ -71,6 +97,20 @@ class ParentNormalizedAnswerBackend(RecordingBackend):
|
|
|
71
97
|
return result
|
|
72
98
|
|
|
73
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
|
+
|
|
74
114
|
def _harness(tmp_path: Path) -> tuple[QuestionHarness, RecordingBackend]:
|
|
75
115
|
schemas = tmp_path / ".prairielearn" / "schemas"
|
|
76
116
|
schemas.mkdir(parents=True)
|
|
@@ -183,6 +223,44 @@ def test_element_owned_answer_supplies_submission_without_correct_answer(
|
|
|
183
223
|
assert result.partial_scores == {"diagram": {"score": 1}}
|
|
184
224
|
|
|
185
225
|
|
|
226
|
+
def test_unassociated_raw_file_field_does_not_require_submitted_answer(
|
|
227
|
+
tmp_path: Path,
|
|
228
|
+
) -> None:
|
|
229
|
+
harness = _answer_harness(
|
|
230
|
+
tmp_path,
|
|
231
|
+
UnassociatedFileFieldBackend(),
|
|
232
|
+
html='<custom-answer answers-name="diagram"></custom-answer>',
|
|
233
|
+
selector="custom-answer",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
result = harness.run_grade(0)
|
|
237
|
+
|
|
238
|
+
assert result.raw_submitted_answers == {
|
|
239
|
+
"diagram": '{"nodes":[1,2]}',
|
|
240
|
+
"_file_transport_token": '[{"name":"notes.txt"}]',
|
|
241
|
+
}
|
|
242
|
+
assert result.submitted_answers == {
|
|
243
|
+
"diagram": '{"nodes":[1,2]}',
|
|
244
|
+
"_files": [{"name": "notes.txt"}],
|
|
245
|
+
}
|
|
246
|
+
assert result.partial_scores == {"diagram": {"score": 1}}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_direct_answer_rejects_unparsed_value(tmp_path: Path) -> None:
|
|
250
|
+
harness = _answer_harness(
|
|
251
|
+
tmp_path,
|
|
252
|
+
ElementOwnedAnswerBackend(null_after_parse=True),
|
|
253
|
+
html='<custom-answer answers-name="diagram"></custom-answer>',
|
|
254
|
+
selector="custom-answer",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
with pytest.raises(
|
|
258
|
+
HarnessError,
|
|
259
|
+
match="raw submission fields did not parse successfully: diagram",
|
|
260
|
+
):
|
|
261
|
+
harness.run_grade(0)
|
|
262
|
+
|
|
263
|
+
|
|
186
264
|
def test_question_test_hook_round_trips_structured_submission(tmp_path: Path) -> None:
|
|
187
265
|
harness = _answer_harness(
|
|
188
266
|
tmp_path,
|
|
@@ -319,3 +397,38 @@ def test_namespaced_raw_value_can_parse_into_declared_parent(tmp_path: Path) ->
|
|
|
319
397
|
|
|
320
398
|
assert result.raw_submitted_answers == {"ordering-input": '[{"id":"a"}]'}
|
|
321
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
|
|
@@ -62,6 +62,9 @@ def prepare_release_repository(
|
|
|
62
62
|
"""#!/usr/bin/env bash
|
|
63
63
|
set -euo pipefail
|
|
64
64
|
printf '%s\\n' "$*" >> "${FAKE_UV_LOG}"
|
|
65
|
+
if [[ -n "${FAKE_UV_FAIL_ON:-}" && "$*" == "${FAKE_UV_FAIL_ON}" ]]; then
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
65
68
|
if [[ "${1:-}" == version && "${2:-}" == --short ]]; then
|
|
66
69
|
printf '%s\\n' "${FAKE_CURRENT_VERSION}"
|
|
67
70
|
fi
|
|
@@ -109,6 +112,13 @@ def test_publish_version_can_tag_an_unpushed_prebumped_commit(tmp_path: Path):
|
|
|
109
112
|
== release_commit
|
|
110
113
|
)
|
|
111
114
|
assert "version 0.4.0" not in uv_log.read_text().splitlines()
|
|
115
|
+
assert uv_log.read_text().splitlines()[1:3] == [
|
|
116
|
+
"python install 3.13",
|
|
117
|
+
"sync --locked --python 3.13",
|
|
118
|
+
]
|
|
119
|
+
assert "run --python 3.13 python scripts/smoke_dist.py" in (
|
|
120
|
+
uv_log.read_text().splitlines()
|
|
121
|
+
)
|
|
112
122
|
assert "Published v0.4.0" in result.stdout
|
|
113
123
|
|
|
114
124
|
|
|
@@ -132,3 +142,28 @@ def test_publish_version_rejects_pushed_prebumped_commit(tmp_path: Path):
|
|
|
132
142
|
|
|
133
143
|
assert result.returncode == 1
|
|
134
144
|
assert "prebumped-version bypass requires an unpushed commit" in result.stderr
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_publish_version_does_not_tag_when_locked_sync_fails(tmp_path: Path):
|
|
148
|
+
repository, remote, environment, uv_log = prepare_release_repository(tmp_path)
|
|
149
|
+
release_commit = git("rev-parse", "HEAD", cwd=repository)
|
|
150
|
+
environment["FAKE_UV_FAIL_ON"] = "sync --locked --python 3.13"
|
|
151
|
+
|
|
152
|
+
result = publish(repository, environment, allow_prebumped=True)
|
|
153
|
+
|
|
154
|
+
assert result.returncode == 1
|
|
155
|
+
assert git("rev-parse", "HEAD", cwd=repository) == release_commit
|
|
156
|
+
assert (
|
|
157
|
+
run(
|
|
158
|
+
"git",
|
|
159
|
+
"--git-dir",
|
|
160
|
+
str(remote),
|
|
161
|
+
"rev-parse",
|
|
162
|
+
"--verify",
|
|
163
|
+
"refs/tags/v0.4.0",
|
|
164
|
+
cwd=tmp_path,
|
|
165
|
+
check=False,
|
|
166
|
+
).returncode
|
|
167
|
+
!= 0
|
|
168
|
+
)
|
|
169
|
+
assert uv_log.read_text().splitlines()[-1] == "sync --locked --python 3.13"
|
|
@@ -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
|