From 04a03d4291c037be387fb9067353bbab86de141c Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Fri, 30 Oct 2026 09:59:22 +0000 Subject: [PATCH 1/2] Accept any test payload, test the answer, and add Greek aliases - Test.payload takes any type, as NUMBER tests in real exports store numbers. - Each response area's answer is written as a passing test, first, unless the answer is empty or a passing test already has it. - InputSymbol.greek builds the aliases of a Greek symbol from its unicode forms, with and without braces around a subscript. Co-Authored-By: Claude Opus 5.5 --- in2lambda/api/response_area.py | 41 +++++++++++++++++++++++++- in2lambda/json_convert/json_convert.py | 13 ++++++-- tests/test_response_area.py | 38 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tests/test_response_area.py 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..ba7d9ad 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 @@ -41,6 +41,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 in (None, "") or 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, @@ -74,7 +83,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": { diff --git a/tests/test_response_area.py b/tests/test_response_area.py new file mode 100644 index 0000000..b086c3c --- /dev/null +++ b/tests/test_response_area.py @@ -0,0 +1,38 @@ +"""Response areas as in2lambda writes them.""" + +import pytest + +from in2lambda.api.response_area import InputSymbol, Number, NumericUnits, Test +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_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") From a04943d07537c1df8f1ea5dee247c4a70bf3e05b Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Fri, 30 Oct 2026 10:04:01 +0000 Subject: [PATCH 2/2] Fail an empty answer's test, and refuse an unmarkable multiple choice - An empty-list answer is no answer, so it is written as a failing test. - A set with a multiple choice that marks no option correct raises ValueError before export, ahead of the output directory being cleared, naming the question and part. Co-Authored-By: Claude Opus 5.5 --- in2lambda/json_convert/json_convert.py | 29 ++++++++++++++++++++------ tests/test_response_area.py | 27 +++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index ba7d9ad..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, Test +from in2lambda.api.response_area import Case, MultipleChoice, ResponseArea, Test from in2lambda.api.set import Set @@ -42,12 +42,16 @@ def _case_to_json(case: Case) -> dict[str, Any]: def _tests(area: ResponseArea) -> list[Test]: - """The area's tests, led by its answer passing unless a test already says so.""" - if area.answer in (None, "") or any( - t.payload == area.answer and t.is_correct for t in area.tests - ): + """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 - return [Test(area.answer, True), *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]: @@ -130,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. @@ -228,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 index b086c3c..1c230e4 100644 --- a/tests/test_response_area.py +++ b/tests/test_response_area.py @@ -2,7 +2,16 @@ import pytest -from in2lambda.api.response_area import InputSymbol, Number, NumericUnits, Test +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 @@ -24,6 +33,10 @@ def test_the_answer_is_written_as_a_passing_test_once() -> None: ] +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}", @@ -36,3 +49,15 @@ def test_greek_aliases_cover_subscripts_and_unicode_forms() -> None: ] 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()