pl-e2e-test 0.1.5__tar.gz → 0.1.7__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 (58) hide show
  1. {pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.7}/PKG-INFO +82 -5
  2. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/README.md +81 -4
  3. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/pyproject.toml +1 -1
  4. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7/src/pl_e2e_test.egg-info}/PKG-INFO +82 -5
  5. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/contract.py +102 -20
  6. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/harness.py +59 -7
  7. pl_e2e_test-0.1.7/tests/test_contract.py +214 -0
  8. pl_e2e_test-0.1.7/tests/test_harness.py +384 -0
  9. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_publish_version.py +35 -0
  10. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_server.py +17 -0
  11. pl_e2e_test-0.1.5/tests/test_contract.py +0 -93
  12. pl_e2e_test-0.1.5/tests/test_harness.py +0 -100
  13. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/LICENSE +0 -0
  14. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/MANIFEST.in +0 -0
  15. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/setup.cfg +0 -0
  16. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
  17. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  18. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  19. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  20. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  21. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/__init__.py +0 -0
  22. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/__main__.py +0 -0
  23. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/_git.py +0 -0
  24. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/backend.py +0 -0
  25. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/cli.py +0 -0
  26. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/config.py +0 -0
  27. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/discovery.py +0 -0
  28. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/errors.py +0 -0
  29. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/expected_errors.py +0 -0
  30. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/fuzz.py +0 -0
  31. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/html_checks.py +0 -0
  32. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/metadata.py +0 -0
  33. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/parallel.py +0 -0
  34. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/pytest_plugin.py +0 -0
  35. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/question_tests.py +0 -0
  36. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/regression.py +0 -0
  37. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/reporting.py +0 -0
  38. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/repository.py +0 -0
  39. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/runner.py +0 -0
  40. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/server.py +0 -0
  41. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/sharding.py +0 -0
  42. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/__init__.py +0 -0
  43. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/conftest.py +0 -0
  44. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/stub_git.py +0 -0
  45. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_cli.py +0 -0
  46. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_config.py +0 -0
  47. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_discovery.py +0 -0
  48. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_fuzz.py +0 -0
  49. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_git.py +0 -0
  50. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_html_checks.py +0 -0
  51. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_metadata.py +0 -0
  52. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_parallel.py +0 -0
  53. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_pytest_plugin.py +0 -0
  54. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_question_tests.py +0 -0
  55. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_regression.py +0 -0
  56. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_reporting.py +0 -0
  57. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_repository.py +0 -0
  58. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/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.5
3
+ Version: 0.1.7
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
@@ -266,14 +266,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
266
266
  ```
267
267
 
268
268
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
269
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
270
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
271
+ answers from grading checks. Each supplied list replaces its default; omitted settings
272
+ keep their defaults, and `[]` disables a list.
272
273
 
273
274
  Use `--config path/to/settings.toml` to select another TOML file with the same
274
275
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
276
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
277
 
278
+ ### Lifecycle JSON boundary
279
+
280
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
281
+ and again after calling the question's `server.py` hook. The harness mirrors those
282
+ boundaries. It first checks the in-process result for legal field mutations, then
283
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
284
+ receives.
285
+
286
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
287
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
288
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
289
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
290
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
291
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
292
+ path.
293
+
294
+ ### Correct submissions for custom elements
295
+
296
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
297
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
298
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
299
+ course element should normally implement the element hook. The question hook can fill
300
+ in a submission when the correct state is question-specific or when a custom element
301
+ does not provide one itself.
302
+
303
+ For every gradable `answers-name`, the correct test case must provide:
304
+
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
307
+ - `data["partial_scores"][answers_name]["score"] = 1`.
308
+
309
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
310
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
311
+ their state as a JSON string. The hook must derive its output only from deterministic
312
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
313
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
314
+
315
+ For example, a question can supply the correct state for a drag-and-drop element with:
316
+
317
+ ```python
318
+ import json
319
+
320
+
321
+ def test(data):
322
+ if data["test_type"] != "correct":
323
+ return
324
+
325
+ answers_name = "ordering"
326
+ state = {
327
+ "solution": data["params"]["correct_order"],
328
+ "starter": [],
329
+ }
330
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
331
+ data["partial_scores"][answers_name] = {"score": 1}
332
+ ```
333
+
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
340
+ non-null value under its declared parent answer. This also supports elements that
341
+ normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
342
+ elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
343
+ parse-coverage obligations.
344
+
345
+ This contract keeps element-specific synthesis with the element or question that owns
346
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
347
+ boundary, and its existing namespaced test fields require no harness adapter. A question
348
+ using `pl-sketch` without an element-owned solution can supply its deterministic
349
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
350
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
351
+ test hook that serializes its prepared correct tray and blank state.
352
+
277
353
  ### Question-local configuration
278
354
 
279
355
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -407,7 +483,8 @@ environment. From a clean branch, run:
407
483
  make publish-version VERSION=0.2.0
408
484
  ```
409
485
 
410
- This runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
486
+ This installs Python 3.13, performs the same locked dependency sync used in CI, runs
487
+ the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
411
488
  distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
412
489
  pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
413
490
  rechecks the tag/version match, tests, builds, generates attestations, and publishes
@@ -242,14 +242,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
242
242
  ```
243
243
 
244
244
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
245
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
246
- display-only answers from grading checks. Each supplied list replaces its default;
247
- omitted settings keep their defaults, and `[]` disables a list.
245
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
246
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
247
+ answers from grading checks. Each supplied list replaces its default; omitted settings
248
+ keep their defaults, and `[]` disables a list.
248
249
 
249
250
  Use `--config path/to/settings.toml` to select another TOML file with the same
250
251
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
251
252
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
252
253
 
254
+ ### Lifecycle JSON boundary
255
+
256
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
257
+ and again after calling the question's `server.py` hook. The harness mirrors those
258
+ boundaries. It first checks the in-process result for legal field mutations, then
259
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
260
+ receives.
261
+
262
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
263
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
264
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
265
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
266
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
267
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
268
+ path.
269
+
270
+ ### Correct submissions for custom elements
271
+
272
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
273
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
274
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
275
+ course element should normally implement the element hook. The question hook can fill
276
+ in a submission when the correct state is question-specific or when a custom element
277
+ does not provide one itself.
278
+
279
+ For every gradable `answers-name`, the correct test case must provide:
280
+
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
283
+ - `data["partial_scores"][answers_name]["score"] = 1`.
284
+
285
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
286
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
287
+ their state as a JSON string. The hook must derive its output only from deterministic
288
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
289
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
290
+
291
+ For example, a question can supply the correct state for a drag-and-drop element with:
292
+
293
+ ```python
294
+ import json
295
+
296
+
297
+ def test(data):
298
+ if data["test_type"] != "correct":
299
+ return
300
+
301
+ answers_name = "ordering"
302
+ state = {
303
+ "solution": data["params"]["correct_order"],
304
+ "starter": [],
305
+ }
306
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
307
+ data["partial_scores"][answers_name] = {"score": 1}
308
+ ```
309
+
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
316
+ non-null value under its declared parent answer. This also supports elements that
317
+ normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
318
+ elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
319
+ parse-coverage obligations.
320
+
321
+ This contract keeps element-specific synthesis with the element or question that owns
322
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
323
+ boundary, and its existing namespaced test fields require no harness adapter. A question
324
+ using `pl-sketch` without an element-owned solution can supply its deterministic
325
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
326
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
327
+ test hook that serializes its prepared correct tray and blank state.
328
+
253
329
  ### Question-local configuration
254
330
 
255
331
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -383,7 +459,8 @@ environment. From a clean branch, run:
383
459
  make publish-version VERSION=0.2.0
384
460
  ```
385
461
 
386
- This runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
462
+ This installs Python 3.13, performs the same locked dependency sync used in CI, runs
463
+ the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
387
464
  distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
388
465
  pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
389
466
  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.5"
7
+ version = "0.1.7"
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.5
3
+ Version: 0.1.7
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
@@ -266,14 +266,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
266
266
  ```
267
267
 
268
268
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
269
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
270
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
271
+ answers from grading checks. Each supplied list replaces its default; omitted settings
272
+ keep their defaults, and `[]` disables a list.
272
273
 
273
274
  Use `--config path/to/settings.toml` to select another TOML file with the same
274
275
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
276
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
277
 
278
+ ### Lifecycle JSON boundary
279
+
280
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
281
+ and again after calling the question's `server.py` hook. The harness mirrors those
282
+ boundaries. It first checks the in-process result for legal field mutations, then
283
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
284
+ receives.
285
+
286
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
287
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
288
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
289
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
290
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
291
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
292
+ path.
293
+
294
+ ### Correct submissions for custom elements
295
+
296
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
297
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
298
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
299
+ course element should normally implement the element hook. The question hook can fill
300
+ in a submission when the correct state is question-specific or when a custom element
301
+ does not provide one itself.
302
+
303
+ For every gradable `answers-name`, the correct test case must provide:
304
+
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
307
+ - `data["partial_scores"][answers_name]["score"] = 1`.
308
+
309
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
310
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
311
+ their state as a JSON string. The hook must derive its output only from deterministic
312
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
313
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
314
+
315
+ For example, a question can supply the correct state for a drag-and-drop element with:
316
+
317
+ ```python
318
+ import json
319
+
320
+
321
+ def test(data):
322
+ if data["test_type"] != "correct":
323
+ return
324
+
325
+ answers_name = "ordering"
326
+ state = {
327
+ "solution": data["params"]["correct_order"],
328
+ "starter": [],
329
+ }
330
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
331
+ data["partial_scores"][answers_name] = {"score": 1}
332
+ ```
333
+
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
340
+ non-null value under its declared parent answer. This also supports elements that
341
+ normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
342
+ elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
343
+ parse-coverage obligations.
344
+
345
+ This contract keeps element-specific synthesis with the element or question that owns
346
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
347
+ boundary, and its existing namespaced test fields require no harness adapter. A question
348
+ using `pl-sketch` without an element-owned solution can supply its deterministic
349
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
350
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
351
+ test hook that serializes its prepared correct tray and blank state.
352
+
277
353
  ### Question-local configuration
278
354
 
279
355
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -407,7 +483,8 @@ environment. From a clean branch, run:
407
483
  make publish-version VERSION=0.2.0
408
484
  ```
409
485
 
410
- This runs the full checks, updates `pyproject.toml` and `uv.lock`, builds the
486
+ This installs Python 3.13, performs the same locked dependency sync used in CI, runs
487
+ the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
411
488
  distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
412
489
  pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
413
490
  rechecks the tag/version match, tests, builds, generates attestations, and publishes
@@ -164,35 +164,117 @@ def validate_mutations(
164
164
  raise HarnessError(f"{phase}: data[{name!r}] was illegally modified")
165
165
 
166
166
 
167
- def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
168
- def check_json(value: Any, path: str) -> None:
169
- if value is None or isinstance(value, (str, bool, int)):
170
- return
171
- if isinstance(value, float):
172
- if not math.isfinite(value):
173
- raise TypeError(f"{path} contains NaN or infinity")
174
- return
175
- if isinstance(value, list):
167
+ _JSON_ERRORS = (TypeError, ValueError, OverflowError, RecursionError)
168
+
169
+
170
+ def _validate_integer_limits(
171
+ value: Any, path: str, active_containers: set[int]
172
+ ) -> None:
173
+ """Apply PrairieLearn's integer limit to every JSON-supported container."""
174
+
175
+ if isinstance(value, int):
176
+ try:
177
+ assert_all_integers_within_limits(value)
178
+ except ValueError as exc:
179
+ raise ValueError(f"{path} contains oversized integer: {value}") from exc
180
+ return
181
+
182
+ if not isinstance(value, (dict, list, tuple)):
183
+ return
184
+
185
+ container_id = id(value)
186
+ if container_id in active_containers:
187
+ return
188
+ active_containers.add(container_id)
189
+ try:
190
+ if isinstance(value, dict):
191
+ for key, item in value.items():
192
+ if isinstance(key, int):
193
+ _validate_integer_limits(
194
+ key, f"{path} object key {key!r}", active_containers
195
+ )
196
+ _validate_integer_limits(item, f"{path}[{key!r}]", active_containers)
197
+ else:
176
198
  for index, item in enumerate(value):
177
- check_json(item, f"{path}[{index}]")
178
- return
199
+ _validate_integer_limits(item, f"{path}[{index}]", active_containers)
200
+ finally:
201
+ active_containers.remove(container_id)
202
+
203
+
204
+ def _json_fails(value: Any) -> bool:
205
+ try:
206
+ json.dumps(value, allow_nan=False)
207
+ except _JSON_ERRORS:
208
+ return True
209
+ return False
210
+
211
+
212
+ def _json_failure_path(value: Any, path: str, active_containers: set[int]) -> str:
213
+ """Locate an encoder failure after json.dumps has already rejected the value."""
214
+
215
+ if not isinstance(value, (dict, list, tuple)):
216
+ return path
217
+
218
+ container_id = id(value)
219
+ if container_id in active_containers:
220
+ return path
221
+ active_containers.add(container_id)
222
+ try:
179
223
  if isinstance(value, dict):
180
224
  for key, item in value.items():
181
- if not isinstance(key, str):
182
- raise TypeError(f"{path} has a non-string object key")
183
- check_json(item, f"{path}[{key!r}]")
184
- return
185
- raise TypeError(f"{path} contains {type(value).__name__}, which is not JSON")
225
+ if _json_fails({key: None}):
226
+ return f"{path} object key {key!r}"
227
+ if _json_fails(item):
228
+ return _json_failure_path(
229
+ item, f"{path}[{key!r}]", active_containers
230
+ )
231
+ else:
232
+ for index, item in enumerate(value):
233
+ if _json_fails(item):
234
+ return _json_failure_path(
235
+ item, f"{path}[{index}]", active_containers
236
+ )
237
+ finally:
238
+ active_containers.remove(container_id)
239
+ return path
240
+
241
+
242
+ def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
243
+ """Validate and normalize data at PrairieLearn's Python-to-JSON boundary."""
186
244
 
187
245
  try:
188
- check_json(data, "data")
189
- assert_all_integers_within_limits(data)
190
- json.dumps(data, allow_nan=False)
191
- except (TypeError, ValueError) as exc:
246
+ _validate_integer_limits(data, "data", set())
247
+ except ValueError as exc:
192
248
  raise HarnessError(
193
249
  f"{phase}: data is not PrairieLearn JSON serializable: {exc}"
194
250
  ) from exc
195
251
 
252
+ try:
253
+ encoded = json.dumps(data, allow_nan=False)
254
+ except _JSON_ERRORS as exc:
255
+ try:
256
+ path = _json_failure_path(data, "data", set())
257
+ except RecursionError:
258
+ path = "data"
259
+ raise HarnessError(
260
+ f"{phase}: data is not PrairieLearn JSON serializable: {path}: {exc}"
261
+ ) from exc
262
+
263
+ try:
264
+ normalized = json.loads(encoded)
265
+ except _JSON_ERRORS as exc:
266
+ raise HarnessError(
267
+ f"{phase}: data is not PrairieLearn JSON serializable: data: {exc}"
268
+ ) from exc
269
+ if not isinstance(normalized, dict):
270
+ raise HarnessError(
271
+ f"{phase}: data is not PrairieLearn JSON serializable: "
272
+ "data did not normalize to an object"
273
+ )
274
+
275
+ data.clear()
276
+ data.update(normalized)
277
+
196
278
 
197
279
  def snapshot(data: dict[str, Any], phase: Phase) -> dict[str, Any]:
198
280
  validate_shape(data, phase)
@@ -68,6 +68,52 @@ def _aggregate_score(data: dict[str, Any], partial_credit: bool) -> None:
68
68
  )
69
69
 
70
70
 
71
+ def _associated_answer_name(raw_name: str, answer_names: tuple[str, ...]) -> str | None:
72
+ if raw_name in answer_names:
73
+ return raw_name
74
+ return max(
75
+ (
76
+ answer_name
77
+ for answer_name in answer_names
78
+ if raw_name.startswith(f"{answer_name}-")
79
+ ),
80
+ key=len,
81
+ default=None,
82
+ )
83
+
84
+
85
+ def _assert_test_submission_coverage(
86
+ data: dict[str, Any], answer_names: tuple[str, ...]
87
+ ) -> None:
88
+ raw_submitted_answers = data["raw_submitted_answers"]
89
+ missing: list[str] = []
90
+ for name in answer_names:
91
+ has_raw_submission = any(
92
+ _associated_answer_name(raw_name, answer_names) == name
93
+ and raw_value is not None
94
+ for raw_name, raw_value in raw_submitted_answers.items()
95
+ )
96
+ partial_score = data["partial_scores"].get(name)
97
+ score = partial_score.get("score") if isinstance(partial_score, dict) else None
98
+ has_full_partial_score = (
99
+ isinstance(score, (int, float))
100
+ and not isinstance(score, bool)
101
+ and float(score) == 1
102
+ )
103
+ if not has_raw_submission or not has_full_partial_score:
104
+ missing.append(name)
105
+
106
+ if missing:
107
+ raise HarnessError(
108
+ "correct test phase did not provide complete submissions for: "
109
+ + ", ".join(missing)
110
+ + ". Implement test(element_html, data) in the owning element or "
111
+ "test(data) in question server.py; populate raw_submitted_answers "
112
+ "with the answer name or namespaced child keys and set "
113
+ "partial_scores[answer_name].score to 1"
114
+ )
115
+
116
+
71
117
  def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) -> None:
72
118
  if data["format_errors"]:
73
119
  raise HarnessError(
@@ -79,15 +125,20 @@ def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) ->
79
125
  raise HarnessError(
80
126
  f"correct submission received score {data['score']!r}, expected 1"
81
127
  )
82
- unparsed = [
83
- name
84
- for name in answer_names
85
- if name not in data["submitted_answers"]
86
- or data["submitted_answers"][name] is None
87
- ]
128
+ submitted_answers = data["submitted_answers"]
129
+ unparsed: list[str] = []
130
+ for raw_name in data["raw_submitted_answers"]:
131
+ parent_name = _associated_answer_name(raw_name, answer_names)
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
+ unparsed.append(raw_name)
88
139
  if unparsed:
89
140
  raise HarnessError(
90
- "declared answers did not parse successfully: " + ", ".join(unparsed)
141
+ "raw submission fields did not parse successfully: " + ", ".join(unparsed)
91
142
  )
92
143
  missing = [name for name in answer_names if name not in data["partial_scores"]]
93
144
  if missing:
@@ -359,6 +410,7 @@ class QuestionHarness:
359
410
  test = self._phase_data("test", base, test_type="correct")
360
411
  self._process("test", source, test, server)
361
412
  _aggregate_score(test, metadata.get("partialCredit", True))
413
+ _assert_test_submission_coverage(test, tested_answer_names)
362
414
 
363
415
  parse = self._phase_data(
364
416
  "parse",