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.
Files changed (56) hide show
  1. {pl_e2e_test-0.1.7/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.8}/PKG-INFO +8 -7
  2. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/README.md +7 -6
  3. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/pyproject.toml +1 -1
  4. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8/src/pl_e2e_test.egg-info}/PKG-INFO +8 -7
  5. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/discovery.py +3 -17
  6. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/harness.py +1 -1
  7. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_discovery.py +28 -18
  8. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_harness.py +50 -0
  9. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_question_tests.py +96 -2
  10. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/LICENSE +0 -0
  11. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/MANIFEST.in +0 -0
  12. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/setup.cfg +0 -0
  13. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
  14. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  15. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  16. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  17. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  18. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__init__.py +0 -0
  19. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/__main__.py +0 -0
  20. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/_git.py +0 -0
  21. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/backend.py +0 -0
  22. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/cli.py +0 -0
  23. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/config.py +0 -0
  24. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/contract.py +0 -0
  25. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/errors.py +0 -0
  26. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/expected_errors.py +0 -0
  27. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/fuzz.py +0 -0
  28. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/html_checks.py +0 -0
  29. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/metadata.py +0 -0
  30. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/parallel.py +0 -0
  31. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/pytest_plugin.py +0 -0
  32. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/question_tests.py +0 -0
  33. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/regression.py +0 -0
  34. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/reporting.py +0 -0
  35. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/repository.py +0 -0
  36. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/runner.py +0 -0
  37. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/server.py +0 -0
  38. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/src/prairielearn_e2e/sharding.py +0 -0
  39. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/__init__.py +0 -0
  40. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/conftest.py +0 -0
  41. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/stub_git.py +0 -0
  42. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_cli.py +0 -0
  43. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_config.py +0 -0
  44. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_contract.py +0 -0
  45. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_fuzz.py +0 -0
  46. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_git.py +0 -0
  47. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_html_checks.py +0 -0
  48. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_metadata.py +0 -0
  49. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_parallel.py +0 -0
  50. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_publish_version.py +0 -0
  51. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_pytest_plugin.py +0 -0
  52. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_regression.py +0 -0
  53. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_reporting.py +0 -0
  54. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_repository.py +0 -0
  55. {pl_e2e_test-0.1.7 → pl_e2e_test-0.1.8}/tests/test_server.py +0 -0
  56. {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.7
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,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. 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 associated with a
339
- declared gradable answer must either remain non-null under its child key or produce a
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-`; 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,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. 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 associated with a
315
- declared gradable answer must either remain non-null under its child key or produce a
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.7"
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.7
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,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. 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 associated with a
339
- declared gradable answer must either remain non-null under its child key or produce a
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 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,
@@ -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:
@@ -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__(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