diff --git a/in2lambda/api/response_area.py b/in2lambda/api/response_area.py index 430dcac..117e530 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,6 +48,26 @@ 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: @@ -37,7 +76,7 @@ class Test: # 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) diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 270ef38..1909d5a 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, MultipleChoice, ResponseArea, Test from in2lambda.api.set import Set @@ -41,6 +41,19 @@ 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. + + An empty list is no answer, so it leads as a failing test instead. + """ + if area.answer in (None, ""): + return area.tests + passes = area.answer != [] + if any(t.payload == area.answer and t.is_correct == passes for t in area.tests): + return area.tests + return [Test(area.answer, passes), *area.tests] + + def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: output = { "orderNumber": order, @@ -74,7 +87,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": { @@ -121,6 +134,18 @@ 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: + if isinstance(area, MultipleChoice) and True not in area.answer: + raise ValueError( + f"Question {i + 1}, part {chr(ord('a') + j)}: " + "a multiple choice answer marks no option correct" + ) + + def converter(SetQuestions: Set, output_dir: str) -> None: """Turns a set of question objects into Lambda Feedback JSON. @@ -219,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..1c230e4 --- /dev/null +++ b/tests/test_response_area.py @@ -0,0 +1,63 @@ +"""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_list_answer_is_written_as_a_failing_test() -> None: + assert _tests(MultipleChoice(options=["A", "B"])) == [([], 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()