Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion in2lambda/api/response_area.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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)

Expand Down
30 changes: 28 additions & 2 deletions in2lambda/json_convert/json_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions tests/test_response_area.py
Original file line number Diff line number Diff line change
@@ -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()
Loading