|
| 1 | +import os |
| 2 | +import tempfile |
| 3 | +import unittest |
| 4 | + |
| 5 | +from lxml import etree |
| 6 | + |
| 7 | +from contentcuration.utils.assessment.qti.validation import _compiled_schema |
| 8 | +from contentcuration.utils.assessment.qti.validation import validate_qti_item |
| 9 | + |
| 10 | + |
| 11 | +class CompiledSchemaTests(unittest.TestCase): |
| 12 | + def test_returns_xml_schema_instance(self): |
| 13 | + self.assertIsInstance(_compiled_schema(), etree.XMLSchema) |
| 14 | + |
| 15 | + def test_is_cached_across_calls(self): |
| 16 | + self.assertIs(_compiled_schema(), _compiled_schema()) |
| 17 | + |
| 18 | + |
| 19 | +def _item_xml(identifier, title, response_declaration, item_body): |
| 20 | + return ( |
| 21 | + '<?xml version="1.0" encoding="UTF-8"?>' |
| 22 | + '<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" ' |
| 23 | + 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' |
| 24 | + 'xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 ' |
| 25 | + 'https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" ' |
| 26 | + 'identifier="%s" title="%s" adaptive="false" time-dependent="false" ' |
| 27 | + 'language="en-US" tool-name="kolibri" tool-version="0.1">' |
| 28 | + "%s" |
| 29 | + '<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />' |
| 30 | + "<qti-item-body>%s</qti-item-body>" |
| 31 | + '<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />' |
| 32 | + "</qti-assessment-item>" |
| 33 | + ) % (identifier, title, response_declaration, item_body) |
| 34 | + |
| 35 | + |
| 36 | +VALID_CHOICE_ITEM = _item_xml( |
| 37 | + "item_1", |
| 38 | + "Sample Item", |
| 39 | + '<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">' |
| 40 | + "<qti-correct-response><qti-value>choice_0</qti-value></qti-correct-response>" |
| 41 | + "</qti-response-declaration>", |
| 42 | + '<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" min-choices="0" ' |
| 43 | + 'orientation="vertical">' |
| 44 | + "<qti-prompt>Select the correct answer.</qti-prompt>" |
| 45 | + '<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">Option A</qti-simple-choice>' |
| 46 | + '<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">Option B</qti-simple-choice>' |
| 47 | + "</qti-choice-interaction>", |
| 48 | +) |
| 49 | + |
| 50 | + |
| 51 | +class ValidateQTIItemTests(unittest.TestCase): |
| 52 | + def test_accepts_valid_item(self): |
| 53 | + result = validate_qti_item(VALID_CHOICE_ITEM) |
| 54 | + self.assertTrue(result.is_valid) |
| 55 | + self.assertEqual(result.errors, []) |
| 56 | + |
| 57 | + def test_accepts_valid_item_as_bytes(self): |
| 58 | + result = validate_qti_item(VALID_CHOICE_ITEM.encode("utf-8")) |
| 59 | + self.assertTrue(result.is_valid) |
| 60 | + |
| 61 | + def test_rejects_invalid_enum_value(self): |
| 62 | + xml = VALID_CHOICE_ITEM.replace( |
| 63 | + 'orientation="vertical"', 'orientation="sideways"' |
| 64 | + ) |
| 65 | + result = validate_qti_item(xml) |
| 66 | + self.assertFalse(result.is_valid) |
| 67 | + self.assertTrue( |
| 68 | + any( |
| 69 | + "orientation" in e.message and "sideways" in e.message |
| 70 | + for e in result.errors |
| 71 | + ) |
| 72 | + ) |
| 73 | + |
| 74 | + def test_rejects_missing_required_attribute(self): |
| 75 | + xml = VALID_CHOICE_ITEM.replace(' time-dependent="false"', "") |
| 76 | + result = validate_qti_item(xml) |
| 77 | + self.assertFalse(result.is_valid) |
| 78 | + self.assertTrue(any("time-dependent" in e.message for e in result.errors)) |
| 79 | + |
| 80 | + def test_rejects_malformed_xml_without_raising(self): |
| 81 | + result = validate_qti_item("<qti-assessment-item><unclosed>") |
| 82 | + self.assertFalse(result.is_valid) |
| 83 | + self.assertEqual(len(result.errors), 1) |
| 84 | + |
| 85 | + def test_does_not_resolve_external_entities(self): |
| 86 | + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: |
| 87 | + f.write("super-secret-value") |
| 88 | + secret_path = f.name |
| 89 | + self.addCleanup(os.remove, secret_path) |
| 90 | + xml = ( |
| 91 | + '<?xml version="1.0"?>' |
| 92 | + '<!DOCTYPE qti-assessment-item [<!ENTITY xxe SYSTEM "file://%s">]>' |
| 93 | + '<qti-assessment-item title="&xxe;"></qti-assessment-item>' % secret_path |
| 94 | + ) |
| 95 | + result = validate_qti_item(xml) |
| 96 | + serialized = " ".join(e.message for e in result.errors) |
| 97 | + self.assertNotIn("super-secret-value", serialized) |
| 98 | + |
| 99 | + |
| 100 | +MATCH_INTERACTION_ITEM = _item_xml( |
| 101 | + "item_match", |
| 102 | + "Match Item", |
| 103 | + '<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="directedPair">' |
| 104 | + "<qti-correct-response><qti-value>A X</qti-value><qti-value>B Y</qti-value></qti-correct-response>" |
| 105 | + "</qti-response-declaration>", |
| 106 | + '<qti-match-interaction response-identifier="RESPONSE" shuffle="false" max-associations="1">' |
| 107 | + "<qti-prompt>Match the fruit to its color.</qti-prompt>" |
| 108 | + "<qti-simple-match-set>" |
| 109 | + '<qti-simple-associable-choice identifier="A" match-max="1">Apple</qti-simple-associable-choice>' |
| 110 | + '<qti-simple-associable-choice identifier="B" match-max="1">Banana</qti-simple-associable-choice>' |
| 111 | + "</qti-simple-match-set>" |
| 112 | + "<qti-simple-match-set>" |
| 113 | + '<qti-simple-associable-choice identifier="X" match-max="1">Red</qti-simple-associable-choice>' |
| 114 | + '<qti-simple-associable-choice identifier="Y" match-max="1">Yellow</qti-simple-associable-choice>' |
| 115 | + "</qti-simple-match-set>" |
| 116 | + "</qti-match-interaction>", |
| 117 | +) |
| 118 | + |
| 119 | +ORDER_INTERACTION_ITEM = _item_xml( |
| 120 | + "item_order", |
| 121 | + "Order Item", |
| 122 | + '<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">' |
| 123 | + "<qti-correct-response>" |
| 124 | + "<qti-value>step1</qti-value><qti-value>step2</qti-value><qti-value>step3</qti-value>" |
| 125 | + "</qti-correct-response>" |
| 126 | + "</qti-response-declaration>", |
| 127 | + '<qti-order-interaction response-identifier="RESPONSE" shuffle="false" orientation="vertical">' |
| 128 | + "<qti-prompt>Order the steps.</qti-prompt>" |
| 129 | + '<qti-simple-choice identifier="step1" show-hide="show" fixed="false">First</qti-simple-choice>' |
| 130 | + '<qti-simple-choice identifier="step2" show-hide="show" fixed="false">Second</qti-simple-choice>' |
| 131 | + '<qti-simple-choice identifier="step3" show-hide="show" fixed="false">Third</qti-simple-choice>' |
| 132 | + "</qti-order-interaction>", |
| 133 | +) |
| 134 | + |
| 135 | + |
| 136 | +class UncoveredInteractionTypeTests(unittest.TestCase): |
| 137 | + """Interaction types the pydantic models / QTIExerciseGenerator never produce.""" |
| 138 | + |
| 139 | + def test_accepts_match_interaction_item(self): |
| 140 | + result = validate_qti_item(MATCH_INTERACTION_ITEM) |
| 141 | + self.assertTrue(result.is_valid) |
| 142 | + self.assertEqual(result.errors, []) |
| 143 | + |
| 144 | + def test_accepts_order_interaction_item(self): |
| 145 | + result = validate_qti_item(ORDER_INTERACTION_ITEM) |
| 146 | + self.assertTrue(result.is_valid) |
| 147 | + self.assertEqual(result.errors, []) |
| 148 | + |
| 149 | + def test_rejects_item_with_unknown_root_element(self): |
| 150 | + result = validate_qti_item('<not-qti identifier="x" />') |
| 151 | + self.assertFalse(result.is_valid) |
| 152 | + self.assertTrue(result.errors) |
| 153 | + |
| 154 | + |
| 155 | +class SchemaReuseTests(unittest.TestCase): |
| 156 | + def test_schema_compiled_once_across_multiple_validate_calls(self): |
| 157 | + _compiled_schema.cache_clear() |
| 158 | + self.addCleanup(_compiled_schema.cache_clear) |
| 159 | + validate_qti_item(VALID_CHOICE_ITEM) |
| 160 | + validate_qti_item(MATCH_INTERACTION_ITEM) |
| 161 | + validate_qti_item(ORDER_INTERACTION_ITEM) |
| 162 | + self.assertEqual(_compiled_schema.cache_info().misses, 1) |
| 163 | + self.assertEqual(_compiled_schema.cache_info().hits, 2) |
0 commit comments