Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
from contentcuration.utils.assessment.qti.assessment_item import ResponseDeclaration
from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing
from contentcuration.utils.assessment.qti.assessment_item import Value
from contentcuration.utils.assessment.qti.catalog import Card
from contentcuration.utils.assessment.qti.catalog import Catalog
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
from contentcuration.utils.assessment.qti.catalog import HtmlContent
from contentcuration.utils.assessment.qti.constants import BaseType
from contentcuration.utils.assessment.qti.constants import Cardinality
from contentcuration.utils.assessment.qti.html import Blockquote
Expand All @@ -31,6 +35,39 @@


class QTIAssessmentItemTests(unittest.TestCase):
def test_assessment_item_with_catalog_info_orders_between_item_body_and_response_processing(
self,
):
item_body = ItemBody(children=[P(children=["Question."])])
response_processing = ResponseProcessing(
template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct"
)
catalog_info = CatalogInfo(
catalog=[
Catalog(
id_="kolibri-hints",
card=[
Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
],
)
]
)

assessment_item = AssessmentItem(
identifier="item1",
title="Test",
language="en-US",
item_body=item_body,
catalog_info=catalog_info,
response_processing=response_processing,
)

xml = assessment_item.to_xml_string()
self.assertLess(xml.index("</qti-item-body>"), xml.index("<qti-catalog-info>"))
self.assertLess(
xml.index("</qti-catalog-info>"), xml.index("<qti-response-processing")
)

def test_true_false_question(self):
expected_xml = """<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
Expand Down
54 changes: 54 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import unittest

from contentcuration.utils.assessment.qti.catalog import Card
from contentcuration.utils.assessment.qti.catalog import Catalog
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
from contentcuration.utils.assessment.qti.catalog import HtmlContent
from contentcuration.utils.assessment.qti.html import P


class CatalogElementXMLOutputTests(unittest.TestCase):
def test_html_content_to_xml_string(self):
html_content = HtmlContent(children=[P(children=["First hint."])])
self.assertEqual(
html_content.to_xml_string(),
"<qti-html-content><p>First hint.</p></qti-html-content>",
)

def test_card_uses_kolibri_hint_support_by_default(self):
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
self.assertEqual(card.support, "ext:kolibri-hint")
self.assertEqual(
card.to_xml_string(),
'<qti-card support="ext:kolibri-hint">'
"<qti-html-content><p>Hint.</p></qti-html-content></qti-card>",
)

def test_catalog_to_xml_string(self):
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
catalog = Catalog(id_="kolibri-hints", card=[card])
self.assertEqual(
catalog.to_xml_string(),
'<qti-catalog id="kolibri-hints"><qti-card support="ext:kolibri-hint">'
"<qti-html-content><p>Hint.</p></qti-html-content>"
"</qti-card></qti-catalog>",
)

def test_catalog_requires_at_least_one_card(self):
with self.assertRaises(ValueError):
Catalog(id_="kolibri-hints", card=[])

def test_catalog_info_to_xml_string(self):
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
catalog_info = CatalogInfo(catalog=[Catalog(id_="kolibri-hints", card=[card])])
self.assertEqual(
catalog_info.to_xml_string(),
"<qti-catalog-info>"
'<qti-catalog id="kolibri-hints"><qti-card support="ext:kolibri-hint">'
"<qti-html-content><p>Hint.</p></qti-html-content>"
"</qti-card></qti-catalog></qti-catalog-info>",
)

def test_catalog_info_requires_at_least_one_catalog(self):
with self.assertRaises(ValueError):
CatalogInfo(catalog=[])
88 changes: 88 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _make_item(
randomize=False,
title="Test Question 1",
language="en-US",
hints=None,
):
return LegacyAssessmentItem(
type=type,
Expand All @@ -39,6 +40,7 @@ def _make_item(
assessment_id=assessment_id,
title=title,
language=language,
hints=hints or [],
)


Expand Down Expand Up @@ -244,3 +246,89 @@ def test_unsupported_type_raises(self):
convert_legacy_assessment_item_to_qti(item)

self.assertIn("Unsupported question type", str(ctx.exception))


class CatalogInfoConversionTests(unittest.TestCase):
def _item_with_hints(self, hints, assessment_id="1234567890abcdef1234567890abcdef"):
return _make_item(
type=exercises.SINGLE_SELECTION,
question="What is 2+2?",
answers=[
{"answer": "4", "correct": True, "order": 1},
{"answer": "3", "correct": False, "order": 2},
],
assessment_id=assessment_id,
hints=hints,
)

def test_no_hints_produces_no_catalog_info(self):
item = self._item_with_hints([])
result = convert_legacy_assessment_item_to_qti(item)
self.assertNotIn("<qti-catalog-info", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_multi_hint_ordering_is_independent_of_input_list_order(self):
item = self._item_with_hints(
[
{"hint": "Second hint", "order": 2},
{"hint": "First hint", "order": 1},
]
)
result = convert_legacy_assessment_item_to_qti(item)
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 2)
self.assertLess(result.xml.index("First hint"), result.xml.index("Second hint"))
self.assertLess(
result.xml.index("</qti-item-body>"), result.xml.index("<qti-catalog-info>")
)
self.assertLess(
result.xml.index("</qti-catalog-info>"),
result.xml.index("<qti-response-processing"),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_hint_with_image_registers_file_dependency(self):
item = self._item_with_hints(
[{"hint": "See ![diagram](images/hint123.png)", "order": 1}]
)
result = convert_legacy_assessment_item_to_qti(item)
self.assertIn('<img alt="diagram" src="images/hint123.png" />', result.xml)
self.assertIn("images/hint123.png", result.file_dependencies)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_hint_missing_order_key_does_not_raise(self):
# A malformed hint must degrade gracefully rather than raise: an uncaught
# exception here would abort the entire channel's publish, not just this item.
item = self._item_with_hints([{"hint": "Undated hint"}])
result = convert_legacy_assessment_item_to_qti(item)
self.assertIn("Undated hint", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_hint_with_incomparable_order_values_does_not_raise(self):
# A mixed-type "order" (e.g. a string alongside an int, or None) makes
# sorted() raise TypeError - this must fall back to input order rather
# than crash the channel publish.
item = self._item_with_hints(
[{"hint": "First hint", "order": 1}, {"hint": "Second hint", "order": "2"}]
)
result = convert_legacy_assessment_item_to_qti(item)
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 2)
self.assertIn("First hint", result.xml)
self.assertIn("Second hint", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_hint_missing_text_key_is_logged_and_skipped(self):
# Graceful-degradation contract for a hint dict with no "hint" value:
# log + skip it, never crash the channel publish. With this the only
# hint, no card survives, so no qti-catalog-info is emitted at all.
item = self._item_with_hints([{"order": 1}])
result = convert_legacy_assessment_item_to_qti(item)
self.assertNotIn("<qti-catalog-info", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_partial_malformed_hints_keep_valid_siblings(self):
# A malformed hint is skipped while its valid siblings still render.
item = self._item_with_hints([{"hint": "Real hint", "order": 1}, {"order": 2}])
result = convert_legacy_assessment_item_to_qti(item)
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 1)
self.assertIn("Real hint", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
33 changes: 33 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,39 @@ def test_convert_legacy_question_to_qti_strips_placeholder_from_question_and_ans
},
)

def test_convert_legacy_question_to_qti_strips_placeholder_from_hints_and_registers_image(
self,
):
question_data = {
"type": "multiple_selection",
"assessment_id": "abf45e8fd7f151adb1b3df2d751e945e",
"question": "Which is red?",
"answers": '[{"answer": "Apple", "correct": true, "order": 0}, {"answer": "Sky", "correct": false, "order": 1}]', # noqa
"hints": '[{"hint": "Try this: ![](${☣ CONTENTSTORAGE}/cccccccccccccccccccccccccccccccc.png)", "order": 0}]', # noqa
"randomize": False,
}
result = convert_legacy_question_to_qti(question_data)
validation_result = validate_qti_item(result.xml)
self.assertTrue(validation_result.is_valid, validation_result.errors)
self.assertIn('support="ext:kolibri-hint"', result.xml)
self.assertIn(
"cccccccccccccccccccccccccccccccc.png",
get_qti_media_references(result.xml),
)

def test_convert_legacy_question_to_qti_without_hints_produces_no_catalog_info(
self,
):
question_data = {
"type": "multiple_selection",
"assessment_id": "abf45e8fd7f151adb1b3df2d751e945e",
"question": "Which is red?",
"answers": '[{"answer": "Apple", "correct": true, "order": 0}, {"answer": "Sky", "correct": false, "order": 1}]', # noqa
"randomize": False,
}
result = convert_legacy_question_to_qti(question_data)
self.assertNotIn("<qti-catalog-info", result.xml)


def _custom_interaction_item_xml(data_type, path_attr, path_value):
return _item_xml(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,48 @@ def test_basic_qti_exercise_creation(self):
_normalize_xml(actual_manifest_xml),
)

def _render_single_item_xml(self, assessment_id, hints):
"""Package a single-question QTI exercise and return that item's XML."""
item = self._create_assessment_item(
exercises.SINGLE_SELECTION,
"What is 2+2?",
[
{"answer": "4", "correct": True, "order": 1},
{"answer": "3", "correct": False, "order": 2},
],
hints=hints,
assessment_id=assessment_id,
)
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.SINGLE_SELECTION},
}
self._create_qti_zip(exercise_data)
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
zip_file = self._validate_qti_zip_structure(exercise_file)
return zip_file.read(f"items/{hex_to_qti_id(assessment_id)}.xml").decode(
"utf-8"
)

def test_qti_exercise_with_hints_produces_catalog_info(self):
item_xml = self._render_single_item_xml(
"1234567890abcdef1234567890abcdef",
[
{"hint": "Think about pairs.", "order": 1},
{"hint": "It's 4.", "order": 2},
],
)
self.assertEqual(item_xml.count('support="ext:kolibri-hint"'), 2)
self.assertLess(item_xml.index("Think about pairs."), item_xml.index("It's 4."))

def test_qti_exercise_without_hints_produces_no_catalog_info(self):
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
self.assertNotIn("<qti-catalog-info", item_xml)

def test_perseus_question_rejection(self):
"""Test that Perseus questions are properly rejected"""
assessment_id = "aaaa1111bbbb2222cccc3333dddd4444"
Expand Down Expand Up @@ -1491,7 +1533,7 @@ def test_exercise_with_image(self):
_normalize_xml(actual_manifest_xml),
)

self.assertEqual(exercise_file.checksum, "8df26b0c7009ae84fe148cceda8e0138")
self.assertEqual(exercise_file.checksum, "cd5a770d35fa1c25092331ee00f4ce4a")

def test_image_resizing(self):
# Create a base image file
Expand Down Expand Up @@ -1580,6 +1622,13 @@ def test_image_resizing(self):
</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-catalog-info>
<qti-catalog id="kolibri-hints">
<qti-card support="ext:kolibri-hint">
<qti-html-content><p>Hint text</p></qti-html-content>
</qti-card>
</qti-catalog>
</qti-catalog-info>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>"""

Expand Down Expand Up @@ -1690,7 +1739,7 @@ def test_multiple_question_types_mixed(self):
_normalize_xml(actual_manifest_xml),
)

self.assertEqual(exercise_file.checksum, "8e488543ef52f0b153553eaf9fb51419")
self.assertEqual(exercise_file.checksum, "f15370f74b06b59bca6e289fe0e9cb87")

def test_unsupported_question_type(self):
"""Test that unsupported question types raise appropriate errors"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def create_assessment_item(
type=assessment_item.type,
question=processed_data["question"],
answers=processed_data.get("answers", []),
hints=processed_data.get("hints", []),
randomize=processed_data.get("randomize", False),
assessment_id=assessment_item.assessment_id,
title=f"{self.ccnode.title} {len(self.qti_resources) + 1}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from contentcuration.utils.assessment.qti.base import BaseSequence
from contentcuration.utils.assessment.qti.base import QTIBase
from contentcuration.utils.assessment.qti.base import TextType
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
from contentcuration.utils.assessment.qti.constants import BaseType
from contentcuration.utils.assessment.qti.constants import Cardinality
from contentcuration.utils.assessment.qti.constants import ExternalScored
Expand Down Expand Up @@ -234,4 +235,5 @@ class AssessmentItem(QTIBase):
response_declaration: List[ResponseDeclaration] = Field(default_factory=list)
outcome_declaration: List[OutcomeDeclaration] = Field(default_factory=list)
item_body: Optional[ItemBody] = None
catalog_info: Optional[CatalogInfo] = None
response_processing: Optional[ResponseProcessing] = None
40 changes: 40 additions & 0 deletions contentcuration/contentcuration/utils/assessment/qti/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Annotated
from typing import List
from typing import Optional
from typing import Union

from annotated_types import Len
from pydantic import Field

from contentcuration.utils.assessment.qti.base import QTIBase
from contentcuration.utils.assessment.qti.html import FlowContent
from contentcuration.utils.assessment.qti.mathml import Math


KOLIBRI_HINT_SUPPORT = "ext:kolibri-hint"


class HtmlContent(QTIBase):
"""Dormant HTML content carried inside a qti-card, per the qti-catalog-info spec."""

children: List[Union[Math, FlowContent]] = Field(default_factory=list)


class Card(QTIBase):
"""A single support-tagged content card within a qti-catalog."""

support: str = KOLIBRI_HINT_SUPPORT
html_content: Optional[HtmlContent] = None


class Catalog(QTIBase):
"""A named collection of cards for a specific support/feature."""

id_: str
card: Annotated[List[Card], Len(min_length=1)]


class CatalogInfo(QTIBase):
"""Dormant, non-delivered catalog content attached to a qti-assessment-item."""

catalog: Annotated[List[Catalog], Len(min_length=1)]
Loading
Loading