From 4c3d20679d3936a04ac3aeb2c7334c1485dbc101 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 19:35:03 +0100 Subject: [PATCH 1/6] implement: Test against real Lambda Feedback exports (t19) --- docs/source/filters.py | 9 +- in2lambda/filters/__init__.py | 18 ++++ in2lambda/json_convert/json_convert.py | 5 +- .../minimal_template_question.json | 1 - in2lambda/main.py | 10 +- tests/conftest.py | 71 ++++++++++++++ tests/test_exports.py | 96 +++++++++++++++++++ tests/test_runner.py | 25 ++--- 8 files changed, 203 insertions(+), 32 deletions(-) create mode 100644 tests/test_exports.py diff --git a/docs/source/filters.py b/docs/source/filters.py index 2002768..483896f 100644 --- a/docs/source/filters.py +++ b/docs/source/filters.py @@ -1,6 +1,5 @@ import importlib import os -import pkgutil import shutil import subprocess from pathlib import Path @@ -19,13 +18,7 @@ def generate_filters_docs(): autosummary_directory.mkdir(exist_ok=True, parents=True) static_pdf_directory.mkdir(exist_ok=True) - filters = ( - i.name - for i in pkgutil.iter_modules(in2lambda.filters.__path__) - if i.name != "markdown" - ) - - for filter_name in filters: + for filter_name in in2lambda.filters.builtin_filters(): filter_module = importlib.import_module( f"in2lambda.filters.{filter_name}.filter" ) diff --git a/in2lambda/filters/__init__.py b/in2lambda/filters/__init__.py index 830e77d..9519688 100644 --- a/in2lambda/filters/__init__.py +++ b/in2lambda/filters/__init__.py @@ -1 +1,19 @@ """Subject specific panflute filters for parsing LaTeX documents.""" + +import pkgutil + + +def builtin_filters() -> list[str]: + """Lists the filters shipped with in2lambda. + + Each filter is a subpackage of this one; ``markdown`` is a helper module they share. + + Returns: + The filter names, as accepted by :func:`in2lambda.main.runner`. + + Examples: + >>> from in2lambda.filters import builtin_filters + >>> "PartsSepSol" in builtin_filters() + True + """ + return [i.name for i in pkgutil.iter_modules(__path__) if i.name != "markdown"] diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index dd85f23..0733454 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -96,12 +96,13 @@ def converter( ListQuestions[i].parts[j].worked_solution ) - # Output file + # Lambda Feedback names the file after the title with only spaces made + # underscores; path separators go too, so a title cannot leave the set folder. filename = ( "question_" + str(i).zfill(3) + "_" - + re.sub(r"[^\w\-_.]", "_", output["title"].strip()) + + re.sub(r"[\s/\\]", "_", output["title"].strip()) ) # write questions into directory diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json index 1f96fc8..db97cf9 100644 --- a/in2lambda/json_convert/minimal_template_question.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -14,7 +14,6 @@ "answerContent": "", "responseAreas": [], "workedSolution": { - "title": "", "content": "Part worked solution here", "children": [] } diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..261a18c 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -7,7 +7,6 @@ # sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import importlib -import pkgutil import subprocess from typing import Optional @@ -168,14 +167,7 @@ def runner( # Python files in the subjects directory @click.argument( "chosen_filter", - type=click.Choice( - [ - i.name - for i in pkgutil.iter_modules(in2lambda.filters.__path__) - if i.name != "markdown" - ], - case_sensitive=False, - ), + type=click.Choice(in2lambda.filters.builtin_filters(), case_sensitive=False), ) @click.option( "--out", diff --git a/tests/conftest.py b/tests/conftest.py index 095b418..9a0eef1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,22 @@ """Shared pytest fixtures for the in2lambda test suite.""" +import json import os +from pathlib import Path import pytest import in2lambda +from in2lambda.api.part import Part +from in2lambda.api.question import Question +from in2lambda.api.set import Set +from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus + +EXPORTS_DIR = Path(__file__).parent / "fixtures" / "exports" +"""Real Lambda Feedback exports, one set per folder, exactly as the platform wrote them.""" + +EXPORTS = sorted(path for path in EXPORTS_DIR.iterdir() if path.is_dir()) +"""Every export folder, found rather than listed so that adding one needs no code.""" @pytest.fixture(scope="session") @@ -14,3 +26,62 @@ def filters_dir() -> str: Each filter ships a self-contained ``example.tex`` used by the end-to-end tests. """ return os.path.join(os.path.dirname(in2lambda.__file__), "filters") + + +def load_export(export_dir: Path) -> Set: + """Reads an exported set into the in2lambda model, keeping only what the model holds. + + The layout followed is the one described in ``fixtures/exports/README.md``. + + Args: + export_dir: A folder holding one exported set. + + Returns: + The set, with each question's images as absolute paths into ``media/``. + """ + (set_file,) = export_dir.glob("set_*.json") + set_json = json.loads(set_file.read_text()) + question_set = Set( + _name=set_json["name"], + _description=set_json["description"], + _finalAnswerVisibility=VisibilityController( + VisibilityStatus(set_json["finalAnswerVisibility"]) + ), + _workedSolutionVisibility=VisibilityController( + VisibilityStatus(set_json["workedSolutionVisibility"]) + ), + _structuredTutorialVisibility=VisibilityController( + VisibilityStatus(set_json["structuredTutorialVisibility"]) + ), + ) + + question_files = sorted( + export_dir.glob("question_*.json"), + key=lambda path: json.loads(path.read_text())["orderNumber"], + ) + media = sorted((export_dir / "media").glob("*")) + for question_file in question_files: + question_json = json.loads(question_file.read_text()) + question_set.questions.append( + Question( + title=question_json["title"], + main_text=question_json["masterContent"], + parts=[ + Part( + text=part["content"], + worked_solution=( + part["workedSolution"]["content"] + if "workedSolution" in part + else "" + ), + ) + for part in question_json["parts"] + ], + images=[ + str(image) + for image in media + if image.name.startswith(f"{question_file.stem}_") + ], + ) + ) + return question_set diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..c460236 --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,96 @@ +"""Round-trips every real Lambda Feedback export through the in2lambda model. + +Each folder in ``fixtures/exports`` is loaded into a :class:`~in2lambda.api.set.Set`, +written back with :meth:`~in2lambda.api.set.Set.to_json` and compared with the +original. The model holds far less than an export, so the comparison covers what it +does hold, the file names written, and that the writer emits no key Lambda Feedback +does not. +""" + +import json +from pathlib import Path + +import pytest +from conftest import EXPORTS, load_export + +from in2lambda.api.set import Set + +pytestmark = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) + + +def _write_back(question_set: Set, tmp_path: Path) -> Path: + question_set.to_json(str(tmp_path / "out")) + return tmp_path / "out" / question_set._name + + +def _relative_files(directory: Path) -> list[str]: + # Finder leaves .DS_Store beside files it has shown; it is not part of an export. + return sorted( + str(path.relative_to(directory)) + for path in directory.rglob("*") + if path.is_file() and not path.name.startswith(".") + ) + + +def _modelled(question_set: Set) -> dict: + # Visibility controllers have no equality, and image paths differ by where the + # set was read from, so compare their values and file names. + return { + "name": question_set._name, + "description": question_set._description, + "visibility": [ + str(question_set._finalAnswerVisibility), + str(question_set._workedSolutionVisibility), + str(question_set._structuredTutorialVisibility), + ], + "questions": [ + (q.title, q.main_text, q.parts, [Path(image).name for image in q.images]) + for q in question_set.questions + ], + } + + +def _key_paths(value, path: str = "") -> set[str]: + if isinstance(value, dict): + paths = set() + for key, item in value.items(): + paths |= {f"{path}.{key}"} | _key_paths(item, f"{path}.{key}") + return paths + if isinstance(value, list): + return set().union(*(_key_paths(item, f"{path}[]") for item in value)) + return set() + + +def _keys_by_kind(directory: Path) -> dict[str, set[str]]: + # Pooled over every set_ or question_ file rather than matched file to file: + # exports leave out components with no content, such as a part's worked solution. + keys: dict[str, set[str]] = {} + for file in directory.glob("*.json"): + kind = file.name.split("_")[0] + keys.setdefault(kind, set()).update(_key_paths(json.loads(file.read_text()))) + return keys + + +def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: + """Writing a loaded export reproduces its file names and reloads to the same set.""" + loaded = load_export(export_dir) + assert loaded.questions + assert all(question.main_text or question.parts for question in loaded.questions) + + written = _write_back(loaded, tmp_path) + + assert _relative_files(written) == _relative_files(export_dir) + assert _modelled(load_export(written)) == _modelled(loaded) + + +def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: + """The writer emits no key, at any depth, that Lambda Feedback never exports there.""" + written = _keys_by_kind(_write_back(load_export(export_dir), tmp_path)) + exported = _keys_by_kind(export_dir) + + missing = { + kind: sorted(keys - exported[kind]) + for kind, keys in written.items() + if keys - exported[kind] + } + assert not missing, missing diff --git a/tests/test_runner.py b/tests/test_runner.py index df31429..7c39d51 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,9 +1,9 @@ """End-to-end tests for :func:`in2lambda.main.runner` across the built-in filters. Each built-in filter ships a self-contained ``example.tex`` that exercises the -document structure it targets. These tests run every filter over its own example -and check both the in-memory :class:`~in2lambda.api.set.Set` and the JSON/ZIP -files written to disk. +document structure it targets. These tests find every filter in the package, run +it over its own example and check both the in-memory :class:`~in2lambda.api.set.Set` +and the JSON/ZIP files written to disk, so a filter shipped without an example fails. """ import json @@ -12,15 +12,20 @@ import pytest from in2lambda.api.set import Set +from in2lambda.filters import builtin_filters from in2lambda.main import runner -BUILTIN_FILTERS = ["PartsSepSol", "PartsOneSol", "PartPartSolSol", "PartSolPartSol"] +def _example(filters_dir: str, filter_name: str) -> str: + path = os.path.join(filters_dir, filter_name, "example.tex") + assert os.path.isfile(path), f"{filter_name} ships no example.tex to test it with" + return path -@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) + +@pytest.mark.parametrize("filter_name", builtin_filters()) def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> None: """Every filter turns its example into a Set with at least one usable question.""" - result = runner(os.path.join(filters_dir, filter_name, "example.tex"), filter_name) + result = runner(_example(filters_dir, filter_name), filter_name) assert isinstance(result, Set) assert result.questions, f"{filter_name} produced no questions" @@ -29,17 +34,13 @@ def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> Non assert question.main_text or question.parts -@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) +@pytest.mark.parametrize("filter_name", builtin_filters()) def test_runner_writes_importable_json( filter_name: str, filters_dir: str, tmp_path ) -> None: """Passing an output directory produces the Lambda Feedback set/ dir and zip.""" out_dir = tmp_path / "out" - result = runner( - os.path.join(filters_dir, filter_name, "example.tex"), - filter_name, - str(out_dir), - ) + result = runner(_example(filters_dir, filter_name), filter_name, str(out_dir)) set_dir = out_dir / "set" assert set_dir.is_dir() From 8d9d6ca4e6bde26ef37ebf2d097cafbc25609ae1 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:31:52 +0100 Subject: [PATCH 2/6] implement: Test against real Lambda Feedback exports (t19) --- in2lambda/json_convert/json_convert.py | 5 ++-- tests/test_exports.py | 37 ++++++++++++++------------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 0733454..9c3dd29 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -97,12 +97,13 @@ def converter( ) # Lambda Feedback names the file after the title with only spaces made - # underscores; path separators go too, so a title cannot leave the set folder. + # underscores. Path separators go too, so a title cannot leave the set folder, + # and so do the characters Windows forbids in file names. filename = ( "question_" + str(i).zfill(3) + "_" - + re.sub(r"[\s/\\]", "_", output["title"].strip()) + + re.sub(r'[\s/\\<>:"|?*]', "_", output["title"].strip()) ) # write questions into directory diff --git a/tests/test_exports.py b/tests/test_exports.py index c460236..9d8f229 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -57,18 +57,21 @@ def _key_paths(value, path: str = "") -> set[str]: paths |= {f"{path}.{key}"} | _key_paths(item, f"{path}.{key}") return paths if isinstance(value, list): - return set().union(*(_key_paths(item, f"{path}[]") for item in value)) + return set().union( + *(_key_paths(item, f"{path}[{i}]") for i, item in enumerate(value)) + ) return set() -def _keys_by_kind(directory: Path) -> dict[str, set[str]]: - # Pooled over every set_ or question_ file rather than matched file to file: - # exports leave out components with no content, such as a part's worked solution. - keys: dict[str, set[str]] = {} - for file in directory.glob("*.json"): - kind = file.name.split("_")[0] - keys.setdefault(kind, set()).update(_key_paths(json.loads(file.read_text()))) - return keys +def _unexported_keys(written: dict, exported: dict) -> list[str]: + missing = _key_paths(written) - _key_paths(exported) + # Lambda Feedback leaves a part's workedSolution out of its export when the part + # has none, but the writer always emits one, so only then may it be absent. + for i, part in enumerate(written.get("parts", [])): + if not part["workedSolution"]["content"]: + prefix = f".parts[{i}].workedSolution" + missing = {key for key in missing if not key.startswith(prefix)} + return sorted(missing) def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: @@ -85,12 +88,12 @@ def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: """The writer emits no key, at any depth, that Lambda Feedback never exports there.""" - written = _keys_by_kind(_write_back(load_export(export_dir), tmp_path)) - exported = _keys_by_kind(export_dir) - - missing = { - kind: sorted(keys - exported[kind]) - for kind, keys in written.items() - if keys - exported[kind] - } + written = _write_back(load_export(export_dir), tmp_path) + + missing = {} + for file in written.glob("*.json"): + exported = json.loads((export_dir / file.name).read_text()) + keys = _unexported_keys(json.loads(file.read_text()), exported) + if keys: + missing[file.name] = keys assert not missing, missing From 4d8dfff265c18483f316fdc8d1e6f817ef1a3c2e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:54:15 +0100 Subject: [PATCH 3/6] implement: Load existing sets and questions from exports (t3) --- in2lambda/api/set.py | 36 +++++++++++ in2lambda/json_convert/json_convert.py | 87 +++++++++++++++++++++++++- tests/conftest.py | 64 ------------------- tests/test_exports.py | 37 ++++++++--- 4 files changed, 149 insertions(+), 75 deletions(-) diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index 58c7a52..a2dd974 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -141,6 +141,42 @@ def to_json(self, output_dir: str) -> None: json_convert.main(self, output_dir) + @classmethod + def from_json(cls, path: str) -> "Set": + """Loads a Lambda Feedback export, as a folder or a zip, into a Set. + + Only what the Set holds is read: the name, description, visibilities, and each + question's title, main text, parts, worked solutions and images. A zip is + extracted to a temporary directory that is not removed afterwards, because the + loaded images point into it. + + Args: + path: The exported set's folder or zip. + + Returns: + The loaded set. + + Raises: + ValueError: If the export does not hold exactly one ``set_*.json``. + + Examples: + >>> import tempfile + >>> s = Set() + >>> s.add_question("Question 1") + >>> s.add_question("Question 2") + >>> with tempfile.TemporaryDirectory() as temp_dir: + ... s.to_json(temp_dir) + ... from_folder = Set.from_json(f"{temp_dir}/set") + ... from_zip = Set.from_json(f"{temp_dir}/set.zip") + >>> [question.title for question in from_folder.questions] + ['Question 1', 'Question 2'] + >>> [question.title for question in from_zip.questions] + ['Question 1', 'Question 2'] + """ + from in2lambda.json_convert import json_convert + + return json_convert.load(path) + def set_name(self, name: str) -> None: """Sets the name of the set. diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 9c3dd29..1fe9364 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -1,15 +1,19 @@ -"""Converts questions from a Python set object into Lambda Feedback JSON.""" +"""Converts questions between a Python set object and Lambda Feedback JSON.""" import json import os import re import shutil +import tempfile import zipfile from copy import deepcopy from pathlib import Path from typing import Any +from in2lambda.api.part import Part +from in2lambda.api.question import Question from in2lambda.api.set import Set +from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json" MINIMAL_SET_TEMPLATE = "minimal_template_set.json" @@ -147,3 +151,84 @@ def main(set_questions: Set, output_dir: str) -> None: except OSError as e: print("Error: %s : %s" % (output_dir, e.strerror)) converter(question_template, set_template, set_questions, output_dir) + + +def load(path: str) -> Set: + """Reads a Lambda Feedback export into a Set, keeping only what the model holds. + + A zip is extracted to a new temporary directory, which is left for the operating + system to clear: the loaded images point into it and must still exist when the + set is written out. + + Args: + path: An exported set, as a folder or a zip, with or without a top-level folder. + + Returns: + The set, with each question's images as absolute paths into ``media/``. + + Raises: + ValueError: If the export does not hold exactly one ``set_*.json``. + """ + root = Path(path) + if root.suffix == ".zip": + extracted = tempfile.mkdtemp(prefix="in2lambda-") + with zipfile.ZipFile(root) as zf: + zf.extractall(extracted) + root = Path(extracted) + + set_files = list(root.rglob("set_*.json")) + if len(set_files) != 1: + raise ValueError(f"Expected one set_*.json in {path}, found {len(set_files)}") + (set_file,) = set_files + export_dir = set_file.parent + + set_json = json.loads(set_file.read_text()) + question_set = Set( + _name=set_json["name"], + _description=set_json["description"], + _finalAnswerVisibility=VisibilityController( + VisibilityStatus(set_json["finalAnswerVisibility"]) + ), + _workedSolutionVisibility=VisibilityController( + VisibilityStatus(set_json["workedSolutionVisibility"]) + ), + _structuredTutorialVisibility=VisibilityController( + VisibilityStatus(set_json["structuredTutorialVisibility"]) + ), + ) + + question_files = sorted( + export_dir.glob("question_*.json"), + key=lambda file: json.loads(file.read_text())["orderNumber"], + ) + media = sorted((export_dir / "media").glob("*")) + for question_file in question_files: + question_json = json.loads(question_file.read_text()) + parts = [ + Part( + text=part["content"], + worked_solution=( + part["workedSolution"]["content"] + if "workedSolution" in part + else "" + ), + ) + for part in question_json["parts"] + ] + question_set.questions.append( + Question( + title=question_json["title"], + main_text=question_json["masterContent"], + parts=parts, + images=[ + str(image) + for image in media + if image.name.startswith(f"{question_file.stem}_") + ], + # Every loaded part already has its text and solution, so further + # add_part_text/add_solution calls must add parts after them rather + # than overwrite the first. + _last_part={"solution": len(parts), "text": len(parts)}, + ) + ) + return question_set diff --git a/tests/conftest.py b/tests/conftest.py index 9a0eef1..ac0da68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,11 @@ """Shared pytest fixtures for the in2lambda test suite.""" -import json import os from pathlib import Path import pytest import in2lambda -from in2lambda.api.part import Part -from in2lambda.api.question import Question -from in2lambda.api.set import Set -from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus EXPORTS_DIR = Path(__file__).parent / "fixtures" / "exports" """Real Lambda Feedback exports, one set per folder, exactly as the platform wrote them.""" @@ -26,62 +21,3 @@ def filters_dir() -> str: Each filter ships a self-contained ``example.tex`` used by the end-to-end tests. """ return os.path.join(os.path.dirname(in2lambda.__file__), "filters") - - -def load_export(export_dir: Path) -> Set: - """Reads an exported set into the in2lambda model, keeping only what the model holds. - - The layout followed is the one described in ``fixtures/exports/README.md``. - - Args: - export_dir: A folder holding one exported set. - - Returns: - The set, with each question's images as absolute paths into ``media/``. - """ - (set_file,) = export_dir.glob("set_*.json") - set_json = json.loads(set_file.read_text()) - question_set = Set( - _name=set_json["name"], - _description=set_json["description"], - _finalAnswerVisibility=VisibilityController( - VisibilityStatus(set_json["finalAnswerVisibility"]) - ), - _workedSolutionVisibility=VisibilityController( - VisibilityStatus(set_json["workedSolutionVisibility"]) - ), - _structuredTutorialVisibility=VisibilityController( - VisibilityStatus(set_json["structuredTutorialVisibility"]) - ), - ) - - question_files = sorted( - export_dir.glob("question_*.json"), - key=lambda path: json.loads(path.read_text())["orderNumber"], - ) - media = sorted((export_dir / "media").glob("*")) - for question_file in question_files: - question_json = json.loads(question_file.read_text()) - question_set.questions.append( - Question( - title=question_json["title"], - main_text=question_json["masterContent"], - parts=[ - Part( - text=part["content"], - worked_solution=( - part["workedSolution"]["content"] - if "workedSolution" in part - else "" - ), - ) - for part in question_json["parts"] - ], - images=[ - str(image) - for image in media - if image.name.startswith(f"{question_file.stem}_") - ], - ) - ) - return question_set diff --git a/tests/test_exports.py b/tests/test_exports.py index 9d8f229..0d12833 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -1,21 +1,22 @@ """Round-trips every real Lambda Feedback export through the in2lambda model. -Each folder in ``fixtures/exports`` is loaded into a :class:`~in2lambda.api.set.Set`, -written back with :meth:`~in2lambda.api.set.Set.to_json` and compared with the -original. The model holds far less than an export, so the comparison covers what it -does hold, the file names written, and that the writer emits no key Lambda Feedback -does not. +Each folder in ``fixtures/exports`` is loaded with +:meth:`~in2lambda.api.set.Set.from_json`, written back with +:meth:`~in2lambda.api.set.Set.to_json` and compared with the original. The model holds +far less than an export, so the comparison covers what it does hold, the file names +written, and that the writer emits no key Lambda Feedback does not. """ import json +import re from pathlib import Path import pytest -from conftest import EXPORTS, load_export +from conftest import EXPORTS from in2lambda.api.set import Set -pytestmark = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) +each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) def _write_back(question_set: Set, tmp_path: Path) -> Path: @@ -74,21 +75,30 @@ def _unexported_keys(written: dict, exported: dict) -> list[str]: return sorted(missing) +@each_export def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: """Writing a loaded export reproduces its file names and reloads to the same set.""" - loaded = load_export(export_dir) + loaded = Set.from_json(str(export_dir)) assert loaded.questions assert all(question.main_text or question.parts for question in loaded.questions) written = _write_back(loaded, tmp_path) assert _relative_files(written) == _relative_files(export_dir) - assert _modelled(load_export(written)) == _modelled(loaded) + assert _modelled(Set.from_json(str(written))) == _modelled(loaded) + assert _modelled(Set.from_json(f"{written}.zip")) == _modelled(loaded) + # Text added to a loaded question is a new part, not a rewrite of the first. + question = Set.from_json(str(export_dir)).questions[0] + texts_before = [part.text for part in question.parts] + question.add_part_text("added") + assert [part.text for part in question.parts] == texts_before + ["added"] + +@each_export def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: """The writer emits no key, at any depth, that Lambda Feedback never exports there.""" - written = _write_back(load_export(export_dir), tmp_path) + written = _write_back(Set.from_json(str(export_dir)), tmp_path) missing = {} for file in written.glob("*.json"): @@ -97,3 +107,10 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: if keys: missing[file.name] = keys assert not missing, missing + + +def test_from_json_rejects_folder_without_set(tmp_path: Path) -> None: + """A folder with no set file is refused with an error that says where it looked.""" + (tmp_path / "question_000_Q.json").write_text("{}") + with pytest.raises(ValueError, match=re.escape(str(tmp_path))): + Set.from_json(str(tmp_path)) From 66b1005bd886c9ce6143519f5839ec663694906c Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:13:19 +0100 Subject: [PATCH 4/6] implement: Model part answers and worked-solution steps (t5) --- in2lambda/api/part.py | 14 ++- in2lambda/api/question.py | 20 ++-- in2lambda/api/response_area.py | 95 ++++++++++++++++ in2lambda/api/set.py | 4 +- in2lambda/json_convert/json_convert.py | 145 +++++++++++++++++++++++-- in2lambda/main.py | 4 +- tests/test_exports.py | 25 +++++ 7 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 in2lambda/api/response_area.py diff --git a/in2lambda/api/part.py b/in2lambda/api/part.py index eae51c9..188174d 100644 --- a/in2lambda/api/part.py +++ b/in2lambda/api/part.py @@ -1,11 +1,21 @@ """A part of a question.""" -from dataclasses import dataclass +from dataclasses import dataclass, field + +from in2lambda.api.response_area import ResponseArea @dataclass class Part: - """A part of a question as represented on Lambda Feedback.""" + """A part of a question as represented on Lambda Feedback. + + ``worked_solution`` is markdown; a line holding only ``---`` (or ``***``) splits it + into the steps students go through one at a time. ``answer`` is the final answer + shown to students, and ``response_areas`` the boxes, in order, that mark what they + type. + """ text: str = "" worked_solution: str = "" + answer: str = "" + response_areas: list[ResponseArea] = field(default_factory=list) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 6ee42d4..42307de 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -89,20 +89,20 @@ def add_solution(self, elem: Union[pf.Element, str]) -> None: >>> question.add_part_text("part a") >>> question.add_solution("part a solution") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='') + Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='') >>> question.add_part_text("part b") >>> question.add_part_text("part c") >>> question.add_solution("Solution for b") >>> # Note that since c doesn't have a solution, it's set to b's solution >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='Solution for b'), \ -Part(text='part c', worked_solution='Solution for b')], images=[], main_text='') + Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \ +Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \ +Part(text='part c', worked_solution='Solution for b', answer='', response_areas=[])], images=[], main_text='') >>> question.add_solution("We now have a solution for c!") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='Solution for b'), \ -Part(text='part c', worked_solution='We now have a solution for c!')], images=[], main_text='') + Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \ +Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \ +Part(text='part c', worked_solution='We now have a solution for c!', answer='', response_areas=[])], images=[], main_text='') """ elem_text = elem if isinstance(elem, str) else pf.stringify(elem) @@ -131,13 +131,13 @@ def add_part_text(self, elem: Union[pf.Element, str]) -> None: >>> question.add_part_text("part a") >>> question.add_solution("part a solution") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='') + Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='') >>> # Supports adding the answer first. >>> question.add_solution("part b solution") >>> question.add_part_text("part b") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='part b solution')], images=[], main_text='') + Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \ +Part(text='part b', worked_solution='part b solution', answer='', response_areas=[])], images=[], main_text='') """ elem_text = elem if isinstance(elem, str) else pf.stringify(elem) diff --git a/in2lambda/api/response_area.py b/in2lambda/api/response_area.py new file mode 100644 index 0000000..99ea6b6 --- /dev/null +++ b/in2lambda/api/response_area.py @@ -0,0 +1,95 @@ +"""An answer box in a part, with how Lambda Feedback marks what is typed into it.""" + +import uuid +from dataclasses import dataclass, field +from typing import Any + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +@dataclass +class InputSymbol: + """A symbol students may type, and what the evaluation function reads it as. + + ``symbol`` is what students see, ``code`` what the evaluator reads, and ``aliases`` + other spellings accepted for it. + """ + + symbol: str + code: str + aliases: list[str] = field(default_factory=list) + is_visible: bool = True + + +@dataclass +class Test: + """An author's check of the marking: a response and whether it should be correct.""" + + # Its name would otherwise make pytest try to collect it wherever it is imported. + __test__ = False + + payload: str + is_correct: bool + id: str = field(default_factory=_new_id) + + +@dataclass +class Case: + """A response that is shown tailored ``feedback``, and may be marked correct.""" + + answer: str + feedback: str + is_correct: bool + params: Any = None + id: str = field(default_factory=_new_id) + + +@dataclass +class ResponseArea: + """An answer box as represented on Lambda Feedback. + + Its position among a part's areas is its order, so it holds no order number. + ``config`` and ``grade_params`` depend on ``response_type`` and are kept as Lambda + Feedback writes them. The feedback colours and prefixes default to what Lambda + Feedback fills in. + + Examples: + >>> from in2lambda.api.response_area import ResponseArea, Test + >>> area = ResponseArea( + ... response_type="NUMERIC_UNITS", + ... answer="30 N", + ... evaluation_function="comparePhysicalQuantities", + ... grade_params={"rtol": 0.05}, + ... pre_text="$F=$", + ... tests=[Test("30 N", True)], + ... ) + >>> area.tests[0].payload, area.tests[0].is_correct + ('30 N', True) + """ + + response_type: str = "MATH_SINGLE_LINE" + """``MATH_SINGLE_LINE``, ``NUMERIC_UNITS`` or ``MULTIPLE_CHOICE``.""" + answer: str | list[bool] = "" + """The correct answer; for multiple choice, one boolean per option.""" + config: dict[str, Any] | None = None + evaluation_function: str = "symbolicEqual" + grade_params: dict[str, Any] | None = None + pre_text: str = "" + post_text: str = "" + content_after: str = "" + """Markdown shown after the box, before the next one.""" + input_symbols: list[InputSymbol] = field(default_factory=list) + display_input_symbols: bool = False + live_preview: bool = False + include_in_pdf: bool = False + save_allowed: bool = False + separate_feedback: bool = True + common_feedback_color: str = "#C4CDD5" + correct_feedback_color: str = "#22C55E" + correct_feedback_prefix: str = "Correct" + incorrect_feedback_color: str = "#ff5630" + incorrect_feedback_prefix: str = "Incorrect" + tests: list[Test] = field(default_factory=list) + cases: list[Case] = field(default_factory=list) diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index a2dd974..8ab08a6 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -102,8 +102,8 @@ def increment_current_question(self) -> None: >>> s.increment_current_question() >>> s.current_question.add_solution("Question 2 answer") >>> s.questions - [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\ - Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')] + [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer', answer='', response_areas=[])], images=[], main_text=''),\ + Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer', answer='', response_areas=[])], images=[], main_text='')] """ self._current_question_index += 1 diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 1fe9364..c78d6c8 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -12,6 +12,7 @@ from in2lambda.api.part import Part from in2lambda.api.question import Question +from in2lambda.api.response_area import Case, InputSymbol, ResponseArea, Test from in2lambda.api.set import Set from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus @@ -35,6 +36,126 @@ def _zip_sorted_folder(folder_path, zip_path): zf.write(abs_path, arcname=rel_path) +def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: + return { + "orderNumber": order, + "contentAfter": area.content_after, + "preResponseText": area.pre_text, + "postResponseText": area.post_text, + "inputSymbols": [ + { + "symbol": symbol.symbol, + "code": symbol.code, + "aliases": symbol.aliases, + "isVisible": symbol.is_visible, + } + for symbol in area.input_symbols + ], + "displayInputSymbols": area.display_input_symbols, + "includeInPdf": area.include_in_pdf, + "saveAllowed": area.save_allowed, + "evaluationFunctionName": area.evaluation_function, + "livePreview": area.live_preview, + "gradeParams": area.grade_params, + "separateFeedback": area.separate_feedback, + "commonFeedbackColor": area.common_feedback_color, + "correctFeedbackColor": area.correct_feedback_color, + "correctFeedbackPrefix": area.correct_feedback_prefix, + "incorrectFeedbackColor": area.incorrect_feedback_color, + "incorrectFeedbackPrefix": area.incorrect_feedback_prefix, + "tests": [ + { + "id": test.id, + "payload": test.payload, + "expectedResponse": {"isCorrect": test.is_correct}, + } + for test in area.tests + ], + "cases": [ + { + "id": case.id, + "answer": case.answer, + "feedback": case.feedback, + "isCorrect": case.is_correct, + "params": case.params, + } + for case in area.cases + ], + "response": { + "responseInput": { + "responseType": area.response_type, + "answer": area.answer, + "config": area.config, + } + }, + } + + +def _response_area_from_json(area: dict[str, Any]) -> ResponseArea: + response = area["response"]["responseInput"] + return ResponseArea( + response_type=response["responseType"], + answer=response["answer"], + config=response["config"], + evaluation_function=area["evaluationFunctionName"], + grade_params=area["gradeParams"], + pre_text=area["preResponseText"], + post_text=area["postResponseText"], + content_after=area["contentAfter"], + input_symbols=[ + InputSymbol( + symbol=symbol["symbol"], + code=symbol["code"], + aliases=symbol["aliases"], + is_visible=symbol["isVisible"], + ) + for symbol in area["inputSymbols"] + ], + display_input_symbols=area["displayInputSymbols"], + live_preview=area["livePreview"], + include_in_pdf=area["includeInPdf"], + save_allowed=area["saveAllowed"], + separate_feedback=area["separateFeedback"], + common_feedback_color=area["commonFeedbackColor"], + correct_feedback_color=area["correctFeedbackColor"], + correct_feedback_prefix=area["correctFeedbackPrefix"], + incorrect_feedback_color=area["incorrectFeedbackColor"], + incorrect_feedback_prefix=area["incorrectFeedbackPrefix"], + tests=[ + Test( + payload=test["payload"], + is_correct=test["expectedResponse"]["isCorrect"], + id=test["id"], + ) + for test in area["tests"] + ], + cases=[ + Case( + answer=case["answer"], + feedback=case["feedback"], + is_correct=case["isCorrect"], + params=case["params"], + id=case["id"], + ) + for case in area["cases"] + ], + ) + + +def _part_to_json( + part: Part, template_part: dict[str, Any], order: int +) -> dict[str, Any]: + output = deepcopy(template_part) + output["orderNumber"] = order + output["content"] = part.text + output["answerContent"] = part.answer + output["responseAreas"] = [ + _response_area_to_json(area, j) for j, area in enumerate(part.response_areas) + ] + output["workedSolution"]["content"] = part.worked_solution + return output + + def converter( question_template: dict[str, Any], set_template: dict[str, Any], @@ -88,17 +209,10 @@ def converter( # add parts to the question file if ListQuestions[i].parts: - output["parts"][0]["content"] = ListQuestions[i].parts[0].text - output["parts"][0]["workedSolution"]["content"] = ( - ListQuestions[i].parts[0].worked_solution - ) - for j in range(1, len(ListQuestions[i].parts)): - output["parts"].append(deepcopy(question_template["parts"][0])) - output["parts"][j]["content"] = ListQuestions[i].parts[j].text - output["parts"][j]["orderNumber"] = j - output["parts"][j]["workedSolution"]["content"] = ( - ListQuestions[i].parts[j].worked_solution - ) + output["parts"] = [ + _part_to_json(part, question_template["parts"][0], j) + for j, part in enumerate(ListQuestions[i].parts) + ] # Lambda Feedback names the file after the title with only spaces made # underscores. Path separators go too, so a title cannot leave the set folder, @@ -212,6 +326,15 @@ def load(path: str) -> Set: if "workedSolution" in part else "" ), + answer=part["answerContent"], + # Exports do not always list areas in order; an area's contentAfter + # leads into the one numbered after it. + response_areas=[ + _response_area_from_json(area) + for area in sorted( + part["responseAreas"], key=lambda area: area["orderNumber"] + ) + ], ) for part in question_json["parts"] ] diff --git a/in2lambda/main.py b/in2lambda/main.py index 261a18c..e6aaf53 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -99,9 +99,9 @@ def runner( >>> from in2lambda.main import runner >>> # Retrieve an example TeX file and run the given filter. >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='This is a sample question\n\n'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[], main_text='This is a sample question\n\n'), ...]) >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...]) """ # The list of questions for Lambda Feedback as a Python API. set_obj = Set() diff --git a/tests/test_exports.py b/tests/test_exports.py index 0d12833..cff5230 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -9,11 +9,15 @@ import json import re +import uuid from pathlib import Path import pytest from conftest import EXPORTS +from in2lambda.api.part import Part +from in2lambda.api.question import Question +from in2lambda.api.response_area import Case, ResponseArea, Test from in2lambda.api.set import Set each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) @@ -65,6 +69,10 @@ def _key_paths(value, path: str = "") -> set[str]: def _unexported_keys(written: dict, exported: dict) -> list[str]: + # An export may list a part's areas out of order; the writer puts them in order, + # so compare each written area with the exported one of the same number. + for part in exported.get("parts", []): + part["responseAreas"].sort(key=lambda area: area["orderNumber"]) missing = _key_paths(written) - _key_paths(exported) # Lambda Feedback leaves a part's workedSolution out of its export when the part # has none, but the writer always emits one, so only then may it be absent. @@ -109,6 +117,23 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: assert not missing, missing +def test_response_area_built_in_python_writes_distinct_ids(tmp_path: Path) -> None: + """Tests and cases given no id are written with a distinct uuid each, as import needs.""" + area = ResponseArea( + tests=[Test("x", True), Test("y", False)], + cases=[Case("x", "", True), Case("y", "", False)], + ) + question_set = Set(questions=[Question(parts=[Part(response_areas=[area])])]) + + written = _write_back(question_set, tmp_path) + (question_file,) = written.glob("question_*.json") + (written_area,) = json.loads(question_file.read_text())["parts"][0]["responseAreas"] + + ids = [item["id"] for item in written_area["tests"] + written_area["cases"]] + assert len(set(ids)) == 4 + assert all(uuid.UUID(id_) for id_ in ids) + + def test_from_json_rejects_folder_without_set(tmp_path: Path) -> None: """A folder with no set file is refused with an error that says where it looked.""" (tmp_path / "question_000_Q.json").write_text("{}") From 1b3b4b7d991673146759a793a6e6c375fb6fef60 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:20:53 +0100 Subject: [PATCH 5/6] implement: Model part answers and worked-solution steps (t5) --- tests/test_exports.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_exports.py b/tests/test_exports.py index cff5230..e3e7b70 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -96,6 +96,21 @@ def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: assert _modelled(Set.from_json(str(written))) == _modelled(loaded) assert _modelled(Set.from_json(f"{written}.zip")) == _modelled(loaded) + # Reloading alone would pass if answers and areas were dropped or mismapped the + # same way both ways, so compare what is written with the export itself. + for file in written.glob("question_*.json"): + written_parts = json.loads(file.read_text())["parts"] + exported_parts = json.loads((export_dir / file.name).read_text())["parts"] + assert [ + (part["answerContent"], part["responseAreas"]) for part in written_parts + ] == [ + ( + part["answerContent"], + sorted(part["responseAreas"], key=lambda area: area["orderNumber"]), + ) + for part in exported_parts + ], file.name + # Text added to a loaded question is a new part, not a rewrite of the first. question = Set.from_json(str(export_dir)).questions[0] texts_before = [part.text for part in question.parts] From 6320529fb53062a24ad95f00a02f343c6d14c13d Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:31:20 +0100 Subject: [PATCH 6/6] implement: Model response areas as exported (t6) --- tests/test_exports.py | 76 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/tests/test_exports.py b/tests/test_exports.py index e3e7b70..5499aaa 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -17,7 +17,7 @@ from in2lambda.api.part import Part from in2lambda.api.question import Question -from in2lambda.api.response_area import Case, ResponseArea, Test +from in2lambda.api.response_area import Case, InputSymbol, ResponseArea, Test from in2lambda.api.set import Set each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) @@ -132,22 +132,76 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: assert not missing, missing -def test_response_area_built_in_python_writes_distinct_ids(tmp_path: Path) -> None: - """Tests and cases given no id are written with a distinct uuid each, as import needs.""" - area = ResponseArea( - tests=[Test("x", True), Test("y", False)], - cases=[Case("x", "", True), Case("y", "", False)], +def _area_shape(area: dict) -> frozenset[str]: + # Without indices, an area's shape is the keys it has, not how many tests, cases + # or symbols it lists. + return frozenset(re.sub(r"\[\d+\]", "[]", path) for path in _key_paths(area)) + + +def test_response_areas_built_in_python_write_as_exported(tmp_path: Path) -> None: + """Boxes of each exported type built in Python reload unchanged, shaped as exported.""" + part = Part( + text="Find the drag, then say whether it scales.", + response_areas=[ + ResponseArea( + response_type="MATH_SINGLE_LINE", + answer="(pi/6)*rho*U**2*R**2", + config={ + "allowPhoto": True, + "allowHandwrite": True, + "enableRefinement": True, + }, + evaluation_function="symbolicEqual", + grade_params={"strict_syntax": False}, + pre_text="$D=$", + content_after="Now put in the numbers.", + input_symbols=[InputSymbol("\\(R\\)", "R", ["r"])], + tests=[Test("(pi/6)*rho*U**2*R**2", True)], + cases=[Case("pi*rho*U**2*R**2", "A factor is missing.", False)], + ), + ResponseArea( + response_type="NUMERIC_UNITS", + answer="30 N", + evaluation_function="comparePhysicalQuantities", + grade_params={"rtol": 0.05, "strict_syntax": False}, + tests=[Test("30 N", True), Test("30", False)], + cases=[ + Case("30 kg m s-2", "Put negative exponents in brackets.", False) + ], + ), + ResponseArea( + response_type="MULTIPLE_CHOICE", + answer=[True, False], + config={"single": True, "options": ["Yes", "No"], "randomise": False}, + evaluation_function="arrayEqual", + ), + ], ) - question_set = Set(questions=[Question(parts=[Part(response_areas=[area])])]) + written = _write_back(Set(questions=[Question(parts=[part])]), tmp_path) + + # Equality includes the ids, so reloading must keep the ones that were written. + assert Set.from_json(str(written)).questions[0].parts == [part] - written = _write_back(question_set, tmp_path) (question_file,) = written.glob("question_*.json") - (written_area,) = json.loads(question_file.read_text())["parts"][0]["responseAreas"] + written_areas = json.loads(question_file.read_text())["parts"][0]["responseAreas"] - ids = [item["id"] for item in written_area["tests"] + written_area["cases"]] - assert len(set(ids)) == 4 + # Import needs every test and case given no id to be written with its own uuid. + ids = [ + item["id"] for area in written_areas for item in area["tests"] + area["cases"] + ] + assert len(set(ids)) == 5 assert all(uuid.UUID(id_) for id_ in ids) + exported_shapes = { + _area_shape(area) + for export_dir in EXPORTS + for file in export_dir.glob("question_*.json") + for exported_part in json.loads(file.read_text())["parts"] + for area in exported_part["responseAreas"] + } + for area in written_areas: + assert _area_shape(area) in exported_shapes, area["response"] + def test_from_json_rejects_folder_without_set(tmp_path: Path) -> None: """A folder with no set file is refused with an error that says where it looked."""