diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..6cbb31a 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -79,4 +79,6 @@ Click on a set in teacher mode. The arrow next to the "Add Question" button allo Choose the zip file you wish to upload, and the question should appear! 🎉 +Imported questions arrive published with every display setting on, and the set's own visibility settings still apply. The Python API can set each of these per question. + ![Importing Question from file in Teacher Mode](_static/images/import-teacher.png) 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..a33dd74 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -1,7 +1,7 @@ """A full question with optional parts that's contained in a set.""" from dataclasses import dataclass, field -from typing import Union +from typing import Optional, Union import panflute as pf @@ -14,10 +14,17 @@ class Question: Each question has a title and is composed of a list of parts. + It also carries the settings Lambda Feedback keeps per question: its skill level, + guidance for students, expected duration in minutes, whether it is published, and + whether students may see the final answer, worked solution, structured tutorial + and chatbot. Unset skill, guidance and durations are left out of the JSON. + Examples: >>> from in2lambda.api.question import Question >>> Question(title="Some title", main_text="Some text") Question(title='Some title', parts=[], images=[], main_text='Some text') + >>> Question(title="Some title", publish=False).publish + False """ title: str = "" @@ -36,6 +43,20 @@ class Question: """Keeps track of the last question part that contains a solution / text.""" + # Settings are left out of the repr so that printing a question still shows its + # content rather than nine lines of configuration. + # An int too: Lambda Feedback's export is written by JavaScript, which writes the + # lowest and highest skill levels as 0 and 1. + skill: Optional[Union[int, float]] = field(default=None, repr=False) + guidance: Optional[str] = field(default=None, repr=False) + duration_lower_bound: Optional[int] = field(default=None, repr=False) + duration_upper_bound: Optional[int] = field(default=None, repr=False) + publish: bool = field(default=True, repr=False) + display_final_answer: bool = field(default=True, repr=False) + display_worked_solution: bool = field(default=True, repr=False) + display_structured_tutorial: bool = field(default=True, repr=False) + display_chatbot: bool = field(default=True, repr=False) + @property def main_text(self) -> str: r"""Main top-level question text. @@ -89,20 +110,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 +152,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..1d00a58 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 @@ -146,9 +146,9 @@ 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. + question's title, main text, parts, worked solutions, images and settings. 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. diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 1fe9364..064bf5c 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,160 @@ 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 _question_json( + question: Question, i: int, template: dict[str, Any] +) -> dict[str, Any]: + output = deepcopy(template) + + output["orderNumber"] = i # order number starts at 0 + output["title"] = question.title if question.title != "" else f"Question {i + 1}" + output["masterContent"] = question.main_text + + output["publish"] = question.publish + output["displayFinalAnswer"] = question.display_final_answer + output["displayWorkedSolution"] = question.display_worked_solution + output["displayStructuredTutorial"] = question.display_structured_tutorial + output["displayChatbot"] = question.display_chatbot + # Unset optional settings are omitted rather than given a value Lambda Feedback + # never chose. + for key, value in { + "skill": question.skill, + "guidance": question.guidance, + "durationLowerBound": question.duration_lower_bound, + "durationUpperBound": question.duration_upper_bound, + }.items(): + if value is not None: + output[key] = value + + if question.parts: + output["parts"] = [ + _part_to_json(part, template["parts"][0], j) + for j, part in enumerate(question.parts) + ] + + return output + + def converter( question_template: dict[str, Any], set_template: dict[str, Any], @@ -74,31 +229,7 @@ def converter( json.dump(set_template, file) for i in range(len(ListQuestions)): - output = deepcopy(question_template) - - output["orderNumber"] = i # order number starts at 0 - # add title to the question file - if ListQuestions[i].title != "": - output["title"] = ListQuestions[i].title - else: - output["title"] = "Question " + str(i + 1) - - # add main text to the question file - output["masterContent"] = ListQuestions[i].main_text - - # 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 = _question_json(ListQuestions[i], i, question_template) # 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, @@ -156,6 +287,9 @@ def main(set_questions: Set, output_dir: str) -> None: def load(path: str) -> Set: """Reads a Lambda Feedback export into a Set, keeping only what the model holds. + That is the set's name, description and visibilities, and each question's title, + main text, parts, worked solutions, images and settings. + 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. @@ -212,6 +346,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"] ] @@ -229,6 +372,15 @@ def load(path: str) -> Set: # add_part_text/add_solution calls must add parts after them rather # than overwrite the first. _last_part={"solution": len(parts), "text": len(parts)}, + skill=question_json.get("skill"), + guidance=question_json.get("guidance"), + duration_lower_bound=question_json.get("durationLowerBound"), + duration_upper_bound=question_json.get("durationUpperBound"), + publish=question_json["publish"], + display_final_answer=question_json["displayFinalAnswer"], + display_worked_solution=question_json["displayWorkedSolution"], + display_structured_tutorial=question_json["displayStructuredTutorial"], + display_chatbot=question_json["displayChatbot"], ) ) return question_set diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json index db97cf9..aea20ad 100644 --- a/in2lambda/json_convert/minimal_template_question.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -2,11 +2,11 @@ "orderNumber": 0, "title": "Question title here", "masterContent": "Top level question here", - "publish": false, + "publish": true, "displayFinalAnswer": true, "displayStructuredTutorial": true, "displayWorkedSolution": true, - "displayChatbot": false, + "displayChatbot": true, "parts": [ { "orderNumber": 0, 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..33900e7 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -9,11 +9,16 @@ import json import re +import uuid +from dataclasses import replace 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, InputSymbol, ResponseArea, Test from in2lambda.api.set import Set each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) @@ -35,7 +40,8 @@ def _relative_files(directory: Path) -> list[str]: 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. + # set was read from, so compare their values and file names. Questions are + # compared whole, so a field added to Question is compared without editing this. return { "name": question_set._name, "description": question_set._description, @@ -45,7 +51,7 @@ def _modelled(question_set: Set) -> dict: str(question_set._structuredTutorialVisibility), ], "questions": [ - (q.title, q.main_text, q.parts, [Path(image).name for image in q.images]) + replace(q, images=[Path(image).name for image in q.images]) for q in question_set.questions ], } @@ -65,6 +71,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. @@ -88,6 +98,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] @@ -109,6 +134,153 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: assert not missing, missing +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", + ), + ], + ) + 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] + + (question_file,) = written.glob("question_*.json") + written_areas = json.loads(question_file.read_text())["parts"][0]["responseAreas"] + + # 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_question_settings_are_written(tmp_path: Path) -> None: + """A question's settings reach its JSON, are left out when unset, and reload.""" + question_set = Set(_name="Settings") + question_set.questions = [ + Question( + title="Configured", + # A whole number, as an export holds the highest skill level; the + # fixture's questions cover fractional ones. + skill=1, + guidance="Try part a first.", + duration_lower_bound=5, + duration_upper_bound=10, + publish=False, + display_final_answer=False, + display_worked_solution=False, + display_structured_tutorial=False, + display_chatbot=False, + ), + Question(title="Default"), + ] + written = _write_back(question_set, tmp_path) + + configured = json.loads((written / "question_000_Configured.json").read_text()) + assert { + key: configured[key] + for key in [ + "skill", + "guidance", + "durationLowerBound", + "durationUpperBound", + "publish", + "displayFinalAnswer", + "displayWorkedSolution", + "displayStructuredTutorial", + "displayChatbot", + ] + } == { + "skill": 1, + "guidance": "Try part a first.", + "durationLowerBound": 5, + "durationUpperBound": 10, + "publish": False, + "displayFinalAnswer": False, + "displayWorkedSolution": False, + "displayStructuredTutorial": False, + "displayChatbot": False, + } + + default = json.loads((written / "question_001_Default.json").read_text()) + assert default["publish"] is True + assert default["displayChatbot"] is True + assert not {"skill", "guidance", "durationLowerBound", "durationUpperBound"} & set( + default + ) + + # Only the settings are compared: a question written without parts reloads with + # the template's placeholder part. + def settings(question: Question) -> list: + return [ + question.skill, + question.guidance, + question.duration_lower_bound, + question.duration_upper_bound, + question.publish, + question.display_final_answer, + question.display_worked_solution, + question.display_structured_tutorial, + question.display_chatbot, + ] + + reloaded = Set.from_json(str(written)).questions + assert [settings(q) for q in reloaded] == [ + settings(q) for q in question_set.questions + ] + + 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("{}")