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
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="K_ty6CYdlQyH-3LoJh2VDIQ" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string" />
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<div>
<p>What is the capital of France?</p>
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50" placeholder-text="Enter your answer here" /></p>
</div>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="K_ty6CYdlQyH-3LoJh2VDIQ" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response>
<qti-value>Nothing</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<div>
<math display="block">
<semantics>
<mrow>
<munderover><mo>∑</mo><mi>n</mi><mi>s</mi></munderover>
<mi>x</mi>
<msup><mi>a</mi><mi>n</mi></msup>
</mrow>
<annotation encoding="application/x-tex">\sum_n^sxa^n</annotation>
</semantics>
</math>
<p>What does this even mean?</p>
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50" placeholder-text="Enter your answer here" /></p>
</div>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="K_ty6CYdlQyH-3LoJh2VDIQ" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
<qti-correct-response>
<qti-value>1</qti-value>
<qti-value>2</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<div>
<p>What positive integers are less than 3?</p>
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50" placeholder-text="Enter your answer here" /></p>
</div>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="K3d3d3d3d3d3d3d3d3d3d3Q" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>choice_0</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" shuffle="true" max-choices="1" min-choices="0" orientation="vertical">
<qti-prompt>
<p>Solve the equation <math display="inline"><semantics><mrow><mfrac><mrow><mi>x</mi></mrow><mrow><mn>2</mn></mrow></mfrac><mo>=</mo><mn>3</mn></mrow><annotation encoding="application/x-tex">\frac{x}{2} = 3</annotation></semantics></math> for x. What is the value of x?</p>
</qti-prompt>
<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false"><p>6</p></qti-simple-choice>
<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false"><p>3</p></qti-simple-choice>
<qti-simple-choice identifier="choice_2" show-hide="show" fixed="false"><p>1.5</p></qti-simple-choice>
<qti-simple-choice identifier="choice_3" show-hide="show" fixed="false"><p>9</p></qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="Kq83vEjRWeJCrze8SNFZ4kA" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="identifier">
<qti-correct-response>
<qti-value>choice_0</qti-value>
<qti-value>choice_1</qti-value>
<qti-value>choice_3</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" shuffle="true" max-choices="4" min-choices="0" orientation="vertical">
<qti-prompt>
<p>Select all prime numbers:</p>
</qti-prompt>
<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false"><p>2</p></qti-simple-choice>
<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false"><p>3</p></qti-simple-choice>
<qti-simple-choice identifier="choice_2" show-hide="show" fixed="false"><p>4</p></qti-simple-choice>
<qti-simple-choice identifier="choice_3" show-hide="show" fixed="false"><p>5</p></qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="KEjRWeJCrze8SNFZ4kKvN7w" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>choice_0</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" shuffle="true" max-choices="1" min-choices="0" orientation="vertical">
<qti-prompt>
<p>What is 2+2?</p>
</qti-prompt>
<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false"><p>4</p></qti-simple-choice>
<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false"><p>3</p></qti-simple-choice>
<qti-simple-choice identifier="choice_2" show-hide="show" fixed="false"><p>5</p></qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" identifier="KEjRWeJCrze8SNFZ4kKvN7w" title="Test Question 1" adaptive="false" time-dependent="false" language="en-US" tool-name="kolibri" tool-version="0.1">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>choice_0</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" shuffle="false" max-choices="1" min-choices="0" orientation="vertical">
<qti-prompt>
<p>Is the sky blue?</p>
</qti-prompt>
<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false"><p>True</p></qti-simple-choice>
<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false"><p>False</p></qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
</qti-assessment-item>
246 changes: 246 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import os
import unittest

from le_utils.constants import exercises

from contentcuration.utils.assessment.qti.convert import (
convert_legacy_assessment_item_to_qti,
)
from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem
from contentcuration.utils.assessment.qti.validation import validate_qti_item


FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")


def _load_fixture(filename):
with open(os.path.join(FIXTURES_DIR, filename)) as f:
return f.read()


def _normalize_xml(xml_string):
return "".join(x.strip() for x in xml_string.split("\n"))


def _make_item(
type,
question,
answers,
assessment_id,
randomize=False,
title="Test Question 1",
language="en-US",
):
return LegacyAssessmentItem(
type=type,
question=question,
answers=answers,
randomize=randomize,
assessment_id=assessment_id,
title=title,
language=language,
)


class ChoiceInteractionConversionTests(unittest.TestCase):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this is now the right place for this, but I think we should also clean up the conversion specific tests from the archive creation tests here: https://github.com/learningequality/studio/blob/unstable/contentcuration/contentcuration/tests/utils/test_exercise_creation.py#L1226

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned up TestQTIExerciseCreation in test_exercise_creation.py: removed test_multiple_selection_question, test_free_response_question, and test_input_question, which only re-asserted per-type conversion XML already covered by test_convert.py. Trimmed test_basic_qti_exercise_creation to drop its duplicate item-XML assertion (kept the zip/manifest checks, which are archive-specific). test_question_with_mathematical_content, test_free_response_question_no_answers, and test_free_response_question_with_maths tested conversion edge cases (MathML rendering, empty-answers response declaration) with no archive-specific behavior, so I ported them into test_convert.py as test_math_content_in_choice_interaction, test_free_response_no_answers, and test_free_response_with_maths (using fixture files per the other thread) rather than dropping that coverage. Left the tests that exercise genuinely archive-specific behavior (manifest structure, image bundling/resizing, native QTI passthrough, Perseus/unsupported-type dispatch) as-is. Full suite still green (29 passed in test_exercise_creation.py, 10 in test_convert.py).

def test_single_selection(self):
item = _make_item(
type=exercises.SINGLE_SELECTION,
question="What is 2+2?",
answers=[
{"answer": "4", "correct": True, "order": 1},
{"answer": "3", "correct": False, "order": 2},
{"answer": "5", "correct": False, "order": 3},
],
randomize=True,
assessment_id="1234567890abcdef1234567890abcdef",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "KEjRWeJCrze8SNFZ4kKvN7w")
self.assertEqual(
_normalize_xml(_load_fixture("single_selection.xml")),
_normalize_xml(result.xml),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_multiple_selection(self):
item = _make_item(
type=exercises.MULTIPLE_SELECTION,
question="Select all prime numbers:",
answers=[
{"answer": "2", "correct": True, "order": 1},
{"answer": "3", "correct": True, "order": 2},
{"answer": "4", "correct": False, "order": 3},
{"answer": "5", "correct": True, "order": 4},
],
randomize=True,
assessment_id="abcdef1234567890abcdef1234567890",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "Kq83vEjRWeJCrze8SNFZ4kA")
self.assertEqual(
_normalize_xml(_load_fixture("multiple_selection.xml")),
_normalize_xml(result.xml),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_true_false(self):
item = _make_item(
type="true_false",
question="Is the sky blue?",
answers=[
{"answer": "True", "correct": True, "order": 1},
{"answer": "False", "correct": False, "order": 2},
],
assessment_id="1234567890abcdef1234567890abcdef",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "KEjRWeJCrze8SNFZ4kKvN7w")
self.assertEqual(
_normalize_xml(_load_fixture("true_false.xml")),
_normalize_xml(result.xml),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_media_reference_survives(self):
item = _make_item(
type=exercises.SINGLE_SELECTION,
question="See the diagram: ![diagram](images/abc123.png)",
answers=[
{
"answer": "Correct ![opt](images/def456.png)",
"correct": True,
"order": 1,
},
{"answer": "Wrong", "correct": False, "order": 2},
],
assessment_id="1234567890abcdef1234567890abcdef",
title="Media Test",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertIn('<img alt="diagram" src="images/abc123.png" />', result.xml)
self.assertIn('<img alt="opt" src="images/def456.png" />', result.xml)
self.assertEqual(
{"images/abc123.png", "images/def456.png"}, set(result.file_dependencies)
)

def test_math_content_in_choice_interaction(self):
item = _make_item(
type=exercises.SINGLE_SELECTION,
question="Solve the equation $$\\frac{x}{2} = 3$$ for x. What is the value of x?",
answers=[
{"answer": "6", "correct": True, "order": 1},
{"answer": "3", "correct": False, "order": 2},
{"answer": "1.5", "correct": False, "order": 3},
{"answer": "9", "correct": False, "order": 4},
],
randomize=True,
assessment_id="dddddddddddddddddddddddddddddddd",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "K3d3d3d3d3d3d3d3d3d3d3Q")
self.assertEqual(
_normalize_xml(_load_fixture("math_content_choice_interaction.xml")),
_normalize_xml(result.xml),
)


class TextEntryInteractionConversionTests(unittest.TestCase):
def test_input_question(self):
item = _make_item(
type=exercises.INPUT_QUESTION,
question="What positive integers are less than 3?",
answers=[
{"answer": 1, "correct": True, "order": 1},
{"answer": 2, "correct": True, "order": 2},
],
randomize=True,
assessment_id="fedcba0987654321fedcba0987654321",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "K_ty6CYdlQyH-3LoJh2VDIQ")
self.assertEqual(
_normalize_xml(_load_fixture("input_question.xml")),
_normalize_xml(result.xml),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_free_response_question(self):
item = _make_item(
type=exercises.FREE_RESPONSE,
question="What positive integers are less than 3?",
answers=[
{"answer": 1, "correct": True, "order": 1},
{"answer": 2, "correct": True, "order": 2},
],
randomize=True,
assessment_id="fedcba0987654321fedcba0987654321",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertIn("<qti-text-entry-interaction", result.xml)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_free_response_no_answers(self):
item = _make_item(
type=exercises.FREE_RESPONSE,
question="What is the capital of France?",
answers=[],
randomize=True,
assessment_id="fedcba0987654321fedcba0987654321",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "K_ty6CYdlQyH-3LoJh2VDIQ")
self.assertEqual(
_normalize_xml(_load_fixture("free_response_no_answers.xml")),
_normalize_xml(result.xml),
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

def test_free_response_with_maths(self):
item = _make_item(
type=exercises.FREE_RESPONSE,
question="$$\\sum_n^sxa^n$$\n\n What does this even mean?",
answers=[{"answer": "Nothing", "correct": True, "order": 1}],
randomize=True,
assessment_id="fedcba0987654321fedcba0987654321",
)

result = convert_legacy_assessment_item_to_qti(item)

self.assertEqual(result.identifier, "K_ty6CYdlQyH-3LoJh2VDIQ")
self.assertEqual(
_normalize_xml(_load_fixture("free_response_with_maths.xml")),
_normalize_xml(result.xml),
)


class UnsupportedTypeConversionTests(unittest.TestCase):
def test_unsupported_type_raises(self):
item = _make_item(
type="NOT_A_REAL_TYPE",
question="x",
answers=[],
assessment_id="1234567890abcdef1234567890abcdef",
title="t",
)

with self.assertRaises(ValueError) as ctx:
convert_legacy_assessment_item_to_qti(item)

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