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.
Files changed (56) hide show
  1. {pl_e2e_test-0.1.6/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.8}/PKG-INFO +14 -9
  2. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/README.md +13 -8
  3. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/pyproject.toml +1 -1
  4. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8/src/pl_e2e_test.egg-info}/PKG-INFO +14 -9
  5. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/discovery.py +3 -17
  6. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/harness.py +7 -7
  7. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_discovery.py +28 -18
  8. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_harness.py +113 -0
  9. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_publish_version.py +35 -0
  10. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_question_tests.py +96 -2
  11. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/LICENSE +0 -0
  12. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/MANIFEST.in +0 -0
  13. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/setup.cfg +0 -0
  14. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
  15. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  16. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  17. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  18. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  19. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__init__.py +0 -0
  20. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__main__.py +0 -0
  21. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/_git.py +0 -0
  22. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/backend.py +0 -0
  23. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/cli.py +0 -0
  24. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/config.py +0 -0
  25. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/contract.py +0 -0
  26. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/errors.py +0 -0
  27. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/expected_errors.py +0 -0
  28. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/fuzz.py +0 -0
  29. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/html_checks.py +0 -0
  30. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/metadata.py +0 -0
  31. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/parallel.py +0 -0
  32. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/pytest_plugin.py +0 -0
  33. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/question_tests.py +0 -0
  34. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/regression.py +0 -0
  35. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/reporting.py +0 -0
  36. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/repository.py +0 -0
  37. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/runner.py +0 -0
  38. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/server.py +0 -0
  39. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/sharding.py +0 -0
  40. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/__init__.py +0 -0
  41. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/conftest.py +0 -0
  42. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/stub_git.py +0 -0
  43. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_cli.py +0 -0
  44. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_config.py +0 -0
  45. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_contract.py +0 -0
  46. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_fuzz.py +0 -0
  47. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_git.py +0 -0
  48. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_html_checks.py +0 -0
  49. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_metadata.py +0 -0
  50. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_parallel.py +0 -0
  51. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_pytest_plugin.py +0 -0
  52. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_regression.py +0 -0
  53. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_reporting.py +0 -0
  54. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_repository.py +0 -0
  55. {pl_e2e_test-0.1.6 → pl_e2e_test-0.1.8}/tests/test_server.py +0 -0
  56. {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.6
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-`; and
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. For example, an element with
336
- `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
337
- then store its score in `partial_scores["match"]`; it does not need to invent a
338
- `submitted_answers["match"]` value. After parsing, every raw field must either remain
339
- non-null under its child key or produce a non-null value under its declared parent
340
- answer. This also supports elements that normalize `answer-input` into `answer`.
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 runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
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-`; and
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. For example, an element with
312
- `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
313
- then store its score in `partial_scores["match"]`; it does not need to invent a
314
- `submitted_answers["match"]` value. After parsing, every raw field must either remain
315
- non-null under its child key or produce a non-null value under its declared parent
316
- answer. This also supports elements that normalize `answer-input` into `answer`.
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 runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.6
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-`; and
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. For example, an element with
336
- `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
337
- then store its score in `partial_scores["match"]`; it does not need to invent a
338
- `submitted_answers["match"]` value. After parsing, every raw field must either remain
339
- non-null under its child key or produce a non-null value under its declared parent
340
- answer. This also supports elements that normalize `answer-input` into `answer`.
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 runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
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 this question contains instructor-only grading content."""
238
-
239
- metadata = self.unvalidated_metadata()
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
- parent_parsed = (
135
- parent_name is not None and submitted_answers.get(parent_name) is not None
136
- )
137
- if not parent_parsed:
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
- def test_manual_grading_question_is_detected(tmp_path: Path) -> None:
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
- (question / "info.json").write_text(
276
- '{"type": "v3", "gradingMethod": "Manual"}', encoding="utf-8"
277
- )
278
- (question / "question.html").write_text(
279
- "<pl-manual-grading-only>guidance</pl-manual-grading-only>",
280
- encoding="utf-8",
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
- "<pl-manual-grading-only>guidance</pl-manual-grading-only>",
302
+ f"<p>Question</p>{manual_content}",
292
303
  encoding="utf-8",
293
304
  )
294
305
 
295
- with pytest.raises(HarnessError, match="does not declare gradingMethod"):
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__(self, audit: ExpectedErrorAudit) -> None:
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