diff --git a/in2lambda/api/response_area.py b/in2lambda/api/response_area.py index 430dcac..297b1fa 100644 --- a/in2lambda/api/response_area.py +++ b/in2lambda/api/response_area.py @@ -1,6 +1,7 @@ """An area for student responses that will receive automated feedback.""" import uuid +from collections.abc import Sequence from dataclasses import dataclass, field from enum import Enum from typing import Any, ClassVar, Optional, Union @@ -13,6 +14,24 @@ def _new_id() -> str: return str(uuid.uuid4()) +_GREEK = { + "alpha": ["α", "𝛼"], + "beta": ["β", "𝛽"], + "gamma": ["γ", "𝛾"], + "delta": ["δ", "𝛿"], + "epsilon": ["ε", "ϵ", "𝜀"], + "theta": ["θ", "𝜃"], + "lambda": ["λ", "𝜆"], + "mu": ["μ", "𝜇"], + "nu": ["ν", "𝜈"], + "rho": ["ρ", "𝜌", "ϱ"], + "sigma": ["σ", "𝜎", "ς"], + "tau": ["τ", "𝜏"], + "phi": ["φ", "ϕ", "𝜙"], + "omega": ["ω", "𝜔"], +} + + @dataclass class InputSymbol: """Defines symbols and how to enter them into a response area. @@ -29,15 +48,41 @@ class InputSymbol: aliases: list[str] = field(default_factory=list) is_visible: bool = True + @classmethod + def greek(cls, symbol: str, code: str, extra: Sequence[str] = ()) -> "InputSymbol": + r"""A Greek symbol whose aliases are its unicode forms. + + ``code`` is a Greek letter name, optionally followed by ``_`` and a subscript. + + Examples: + >>> InputSymbol.greek("$\\rho$", "rho").aliases + ['ρ', '𝜌', 'ϱ'] + """ + name, _, sub = code.partition("_") + if name not in _GREEK: + raise ValueError(f"{name!r} is not a Greek letter name") + aliases = [] + # Never "sigmaf": the subscript could be read as a symbol of its own. + for base in [name, *_GREEK[name]]: + aliases += [f"{base}_{sub}", f"{base}_{{{sub}}}"] if sub else [base] + aliases += extra + return cls(symbol, code, [a for a in dict.fromkeys(aliases) if a != code]) + @dataclass class Test: - """Teacher author's assertions of how responses should be evaluated.""" + """Teacher author's assertions of how responses should be evaluated. + + ``payload`` is a sample student response, so it takes the type of the area's + answer: a number for ``Number``, a list of booleans for ``MultipleChoice``, a + string for ``NumericUnits``. Each area checks its tests' payloads when the set + is written. + """ # Its name would otherwise make pytest try to collect it wherever it is imported. __test__ = False - payload: str + payload: Any is_correct: bool id: str = field(default_factory=_new_id) @@ -85,6 +130,8 @@ class ResponseArea: """ response_type: ClassVar[str] + payload_types: ClassVar[tuple[type, ...]] = (object,) + """The types a test payload, a sample response, may take.""" answer: Any = None evaluation_function: str = UNSET grade_params: Optional[dict[str, Any]] = None @@ -120,9 +167,20 @@ def _grade_params(self) -> Optional[dict[str, Any]]: """The parameters sent to the evaluation function.""" return self.grade_params + def _validate(self) -> None: + """Raises ValueError if Lambda Feedback could not mark the area. + + Subclasses extend this with the rules of their response type. + """ + for test in self.tests: + if not isinstance(test.payload, self.payload_types): + names = " or ".join(t.__name__ for t in self.payload_types) + raise ValueError(f"test payload {test.payload!r} is not {names}") + @dataclass(kw_only=True) class _Maths(ResponseArea): + payload_types: ClassVar[tuple[type, ...]] = (str,) answer: str = "" allow_handwrite: bool = UNSET allow_photo: bool = UNSET @@ -155,14 +213,23 @@ class Number(ResponseArea): """A number.""" response_type: ClassVar[str] = "NUMBER" + payload_types: ClassVar[tuple[type, ...]] = (int, float) answer: Optional[Union[int, float]] = None + def _validate(self) -> None: + super()._validate() + # bool is a subclass of int, but True is not a number response. + for test in self.tests: + if isinstance(test.payload, bool): + raise ValueError(f"test payload {test.payload!r} is not int or float") + @dataclass(kw_only=True) class NumericUnits(ResponseArea): """A number with units, e.g. ``30 N``.""" response_type: ClassVar[str] = "NUMERIC_UNITS" + payload_types: ClassVar[tuple[type, ...]] = (str,) answer: str = "" def _grade_params(self) -> Optional[dict[str, Any]]: @@ -175,6 +242,7 @@ class Text(ResponseArea): """A short piece of text.""" response_type: ClassVar[str] = "TEXT" + payload_types: ClassVar[tuple[type, ...]] = (str,) answer: str = "" @@ -183,6 +251,7 @@ class Boolean(ResponseArea): """True or false.""" response_type: ClassVar[str] = "BOOLEAN" + payload_types: ClassVar[tuple[type, ...]] = (bool,) answer: bool = False @@ -196,6 +265,8 @@ class MultipleChoice(ResponseArea): single: bool = UNSET randomise: bool = UNSET + payload_types: ClassVar[tuple[type, ...]] = (list,) + def _config(self) -> Optional[dict[str, Any]]: return { "options": self.options, @@ -203,12 +274,22 @@ def _config(self) -> Optional[dict[str, Any]]: "single": self.single, } + def _validate(self) -> None: + super()._validate() + if True not in self.answer: + raise ValueError("a multiple choice answer marks no option correct") + n = len(self.options) + for marks in [self.answer, *(t.payload for t in self.tests)]: + if len(marks) != n or not all(isinstance(m, bool) for m in marks): + raise ValueError(f"{marks!r} is not {n} booleans, one per option") + @dataclass(kw_only=True) class Table(ResponseArea): """A grid of cells, one row per row name and one column per column name.""" response_type: ClassVar[str] = "TABLE" + payload_types: ClassVar[tuple[type, ...]] = (list,) answer: list[list[str]] = field(default_factory=list) row_names: list[str] = field(default_factory=list) col_names: list[str] = field(default_factory=list) @@ -227,6 +308,7 @@ class Matrix(ResponseArea): """A table, with parentheses displayed around it, with the shape of ``answer``.""" response_type: ClassVar[str] = "MATRIX" + payload_types: ClassVar[tuple[type, ...]] = (list,) answer: list[list[str]] = field(default_factory=list) def _config(self) -> Optional[dict[str, Any]]: @@ -241,6 +323,7 @@ class Code(ResponseArea): """A monaco code editor.""" response_type: ClassVar[str] = "CODE" + payload_types: ClassVar[tuple[type, ...]] = (str,) answer: str = "" language: str = UNSET diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index aa9548f..62f3ae9 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -8,7 +8,7 @@ from typing import Any from in2lambda.api.part import Branch, Part -from in2lambda.api.response_area import Case, ResponseArea +from in2lambda.api.response_area import Case, ResponseArea, Test from in2lambda.api.set import Set @@ -43,6 +43,15 @@ def _case_to_json(case: Case) -> dict[str, Any]: return output +def _tests(area: ResponseArea) -> list[Test]: + """The area's tests, led by its answer passing unless a test already says so.""" + if area.answer is None or area.answer == "" or area.answer == []: + return area.tests + if any(t.payload == area.answer and t.is_correct for t in area.tests): + return area.tests + return [Test(area.answer, True), *area.tests] + + def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: output = { "orderNumber": order, @@ -76,7 +85,7 @@ def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: "payload": test.payload, "expectedResponse": {"isCorrect": test.is_correct}, } - for test in area.tests + for test in _tests(area) ], "cases": [_case_to_json(case) for case in area.cases], "response": { @@ -123,6 +132,19 @@ def _part_to_json(part: Part, order: int) -> dict[str, Any]: return output +def _check(set_questions: Set) -> None: + """Raises ValueError for a set Lambda Feedback could not mark.""" + for i, question in enumerate(set_questions.questions): + for j, part in enumerate(question.parts): + for area in part.response_areas: + try: + area._validate() + except ValueError as error: + raise ValueError( + f"Question {i + 1}, part {chr(ord('a') + j)}: {error}" + ) from None + + def converter(SetQuestions: Set, output_dir: str) -> None: """Turns a set of question objects into Lambda Feedback JSON. @@ -222,6 +244,7 @@ def main(set_questions: Set, output_dir: str) -> None: set_questions: A Set object containing questions. output_dir: Where to output the final Lambda Feedback JSON/ZIP files. """ + _check(set_questions) # check if directory exists in file if os.path.isdir(output_dir): try: diff --git a/tests/test_response_area.py b/tests/test_response_area.py new file mode 100644 index 0000000..ad2f775 --- /dev/null +++ b/tests/test_response_area.py @@ -0,0 +1,99 @@ +"""Response areas as in2lambda writes them.""" + +import pytest + +from in2lambda.api.part import Part +from in2lambda.api.question import Question +from in2lambda.api.response_area import ( + InputSymbol, + MultipleChoice, + Number, + NumericUnits, + Test, +) +from in2lambda.api.set import Set +from in2lambda.json_convert.json_convert import _response_area_to_json + + +def _tests(area) -> list: + return [ + (t["payload"], t["expectedResponse"]["isCorrect"]) + for t in _response_area_to_json(area, 0)["tests"] + ] + + +def test_a_test_payload_keeps_its_python_type() -> None: + assert _tests(Number(tests=[Test(2.5, True)])) == [(2.5, True)] + + +def test_the_answer_is_written_as_a_passing_test_once() -> None: + assert _tests(NumericUnits(answer="4 m/s")) == [("4 m/s", True)] + assert _tests(NumericUnits(answer="4 m/s", tests=[Test("4 m/s", True)])) == [ + ("4 m/s", True) + ] + + +def test_an_empty_answer_adds_no_test() -> None: + assert _tests(MultipleChoice(options=["A", "B"])) == [] + + +def _write(tmp_path, area) -> None: + Set(questions=[Question(parts=[Part(response_areas=[area])])]).to_json( + str(tmp_path) + ) + + +@pytest.mark.parametrize( + "area", + [ + Number(answer=2.5, tests=[Test("2.5", True)]), + Number(answer=1, tests=[Test(True, True)]), + NumericUnits(answer="4 m/s", tests=[Test(4, True)]), + MultipleChoice( + options=["A", "B"], answer=[True, False], tests=[Test([True], True)] + ), + MultipleChoice(options=["A", "B"], answer=[True]), + MultipleChoice( + options=["A", "B"], answer=[True, False], tests=[Test("A", True)] + ), + ], +) +def test_a_payload_of_the_wrong_type_or_shape_is_refused(tmp_path, area) -> None: + with pytest.raises(ValueError, match="Question 1, part a"): + _write(tmp_path, area) + + +def test_payloads_of_the_answer_type_are_written(tmp_path) -> None: + _write(tmp_path, Number(answer=2.5, tests=[Test(3, False)])) + _write( + tmp_path, + MultipleChoice( + options=["A", "B"], answer=[True, False], tests=[Test([False, True], False)] + ), + ) + + +def test_greek_aliases_cover_subscripts_and_unicode_forms() -> None: + assert InputSymbol.greek("$\\sigma_f$", "sigma_f").aliases == [ + "sigma_{f}", + "σ_f", + "σ_{f}", + "𝜎_f", + "𝜎_{f}", + "ς_f", + "ς_{f}", + ] + with pytest.raises(ValueError): + InputSymbol.greek("$x$", "x") + + +def test_a_set_with_no_correct_multiple_choice_option_is_not_written( + tmp_path, +) -> None: + area = MultipleChoice(options=["A", "B"], answer=[False, False]) + s = Set(questions=[Question(parts=[Part(response_areas=[area])])]) + earlier = tmp_path / "earlier.json" + earlier.write_text("{}") + with pytest.raises(ValueError, match="Question 1, part a"): + s.to_json(str(tmp_path)) + assert earlier.exists()