|
14 | 14 | from contentcuration.tests.viewsets.base import generate_delete_event |
15 | 15 | from contentcuration.tests.viewsets.base import generate_update_event |
16 | 16 | from contentcuration.tests.viewsets.base import SyncTestMixin |
| 17 | +from contentcuration.utils.assessment.qti.validation import validate_qti_item |
17 | 18 | from contentcuration.viewsets.sync.constants import ASSESSMENTITEM |
18 | 19 |
|
19 | 20 |
|
|
35 | 36 | "</qti-choice-interaction>", |
36 | 37 | ) |
37 | 38 |
|
| 39 | +CHOICE_ANSWERS = json.dumps( |
| 40 | + [ |
| 41 | + {"answer": "4", "correct": True, "order": 1}, |
| 42 | + {"answer": "5", "correct": False, "order": 2}, |
| 43 | + ] |
| 44 | +) |
| 45 | +TEXT_ANSWERS = json.dumps([{"answer": "4", "correct": True, "order": 1}]) |
| 46 | + |
38 | 47 |
|
39 | 48 | class SyncTestCase(SyncTestMixin, StudioAPITestCase): |
40 | 49 | @property |
@@ -1177,6 +1186,220 @@ def test_delete_assessmentitem(self): |
1177 | 1186 | self.assertEqual(response.status_code, 405, response.content) |
1178 | 1187 |
|
1179 | 1188 |
|
| 1189 | +class DualReadTestCase(StudioAPITestCase): |
| 1190 | + def setUp(self): |
| 1191 | + super(DualReadTestCase, self).setUp() |
| 1192 | + self.channel = testdata.channel() |
| 1193 | + self.user = testdata.user() |
| 1194 | + self.channel.editors.add(self.user) |
| 1195 | + self.node = ( |
| 1196 | + self.channel.main_tree.get_descendants() |
| 1197 | + .filter(kind_id=content_kinds.EXERCISE) |
| 1198 | + .first() |
| 1199 | + ) |
| 1200 | + self.client.force_authenticate(user=self.user) |
| 1201 | + |
| 1202 | + def _create_item(self, node=None, **kwargs): |
| 1203 | + return models.AssessmentItem.objects.create( |
| 1204 | + contentnode=node or self.node, assessment_id=uuid.uuid4().hex, **kwargs |
| 1205 | + ) |
| 1206 | + |
| 1207 | + def _list_items(self, **query): |
| 1208 | + # The fixture node carries its own assessment items, so key the response |
| 1209 | + # by assessment_id rather than indexing it. |
| 1210 | + response = self.client.get(reverse("assessmentitem-list"), query) |
| 1211 | + self.assertEqual(response.status_code, 200, response.content) |
| 1212 | + return {item["assessment_id"]: item for item in response.json()} |
| 1213 | + |
| 1214 | + def _get_item(self, assessment_id): |
| 1215 | + return self._list_items(contentnode=self.node.id)[assessment_id] |
| 1216 | + |
| 1217 | + def test_supported_legacy_types_returned_as_qti(self): |
| 1218 | + cases = [ |
| 1219 | + (exercises.SINGLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), |
| 1220 | + (exercises.MULTIPLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), |
| 1221 | + ("true_false", CHOICE_ANSWERS, "qti-choice-interaction"), |
| 1222 | + (exercises.INPUT_QUESTION, TEXT_ANSWERS, "qti-text-entry-interaction"), |
| 1223 | + (exercises.FREE_RESPONSE, TEXT_ANSWERS, "qti-text-entry-interaction"), |
| 1224 | + ] |
| 1225 | + created = { |
| 1226 | + item_type: self._create_item( |
| 1227 | + type=item_type, |
| 1228 | + question="What is 2+2?", |
| 1229 | + answers=answers, |
| 1230 | + hints=json.dumps([{"hint": "Count.", "order": 1}]), |
| 1231 | + ).assessment_id |
| 1232 | + for item_type, answers, _ in cases |
| 1233 | + } |
| 1234 | + |
| 1235 | + items = self._list_items(contentnode=self.node.id) |
| 1236 | + |
| 1237 | + for item_type, _, interaction in cases: |
| 1238 | + with self.subTest(type=item_type): |
| 1239 | + item = items[created[item_type]] |
| 1240 | + self.assertEqual(item["type"], exercises.QTI) |
| 1241 | + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) |
| 1242 | + self.assertIn(interaction, item["raw_data"]) |
| 1243 | + |
| 1244 | + def test_answerless_choice_item_is_returned_as_valid_qti(self): |
| 1245 | + # The shape the editor writes for every newly added question. |
| 1246 | + assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id |
| 1247 | + |
| 1248 | + item = self._get_item(assessment_id) |
| 1249 | + |
| 1250 | + self.assertEqual(item["type"], exercises.QTI) |
| 1251 | + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) |
| 1252 | + |
| 1253 | + def test_converted_item_has_no_legacy_field_content(self): |
| 1254 | + assessment_id = self._create_item( |
| 1255 | + type=exercises.SINGLE_SELECTION, |
| 1256 | + question="What is 2+2?", |
| 1257 | + answers=CHOICE_ANSWERS, |
| 1258 | + hints=json.dumps([{"hint": "Count.", "order": 1}]), |
| 1259 | + ).assessment_id |
| 1260 | + |
| 1261 | + item = self._get_item(assessment_id) |
| 1262 | + |
| 1263 | + self.assertEqual(item["question"], "") |
| 1264 | + self.assertEqual(item["answers"], "[]") |
| 1265 | + self.assertEqual(item["hints"], "[]") |
| 1266 | + # The node language is only in the values tuple to feed the conversion. |
| 1267 | + self.assertNotIn("contentnode__language__lang_code", item) |
| 1268 | + |
| 1269 | + def test_converted_items_are_tagged_with_their_own_node_language(self): |
| 1270 | + # A contentnode__in read spans several nodes, so each item must pick up its own |
| 1271 | + # node's language. pt-BR's bare lang_code differs from its primary key, so the |
| 1272 | + # assertions also pin which of the two publish tags an item with. |
| 1273 | + self.node.language = models.Language.objects.get(id="pt-BR") |
| 1274 | + self.node.save() |
| 1275 | + other_node = models.ContentNode.objects.create( |
| 1276 | + id=uuid.uuid4().hex, |
| 1277 | + title="Exercise 2", |
| 1278 | + kind_id=content_kinds.EXERCISE, |
| 1279 | + parent=self.node.parent, |
| 1280 | + language=models.Language.objects.get(id="fr"), |
| 1281 | + ) |
| 1282 | + first = self._create_item( |
| 1283 | + type=exercises.SINGLE_SELECTION, |
| 1284 | + question="What is 2+2?", |
| 1285 | + answers=CHOICE_ANSWERS, |
| 1286 | + ) |
| 1287 | + second = self._create_item( |
| 1288 | + node=other_node, |
| 1289 | + type=exercises.SINGLE_SELECTION, |
| 1290 | + question="What is 3+3?", |
| 1291 | + answers=CHOICE_ANSWERS, |
| 1292 | + ) |
| 1293 | + |
| 1294 | + items = self._list_items( |
| 1295 | + contentnode__in=f"{self.node.id},{other_node.id}", |
| 1296 | + ) |
| 1297 | + |
| 1298 | + self.assertIn('xml:lang="pt"', items[first.assessment_id]["raw_data"]) |
| 1299 | + self.assertNotIn("pt-BR", items[first.assessment_id]["raw_data"]) |
| 1300 | + self.assertIn('xml:lang="fr"', items[second.assessment_id]["raw_data"]) |
| 1301 | + |
| 1302 | + def test_converted_item_falls_back_to_the_channel_language(self): |
| 1303 | + # What publish does for a node with no language of its own. |
| 1304 | + self.channel.language = models.Language.objects.get(id="pt-BR") |
| 1305 | + self.channel.save() |
| 1306 | + assessment_id = self._create_item( |
| 1307 | + type=exercises.SINGLE_SELECTION, |
| 1308 | + question="What is 2+2?", |
| 1309 | + answers=CHOICE_ANSWERS, |
| 1310 | + ).assessment_id |
| 1311 | + |
| 1312 | + item = self._get_item(assessment_id) |
| 1313 | + |
| 1314 | + self.assertIn('xml:lang="pt"', item["raw_data"]) |
| 1315 | + self.assertNotIn("pt-BR", item["raw_data"]) |
| 1316 | + |
| 1317 | + def test_converted_item_defaults_to_english_without_any_language(self): |
| 1318 | + self.channel.language = None |
| 1319 | + self.channel.save() |
| 1320 | + assessment_id = self._create_item( |
| 1321 | + type=exercises.SINGLE_SELECTION, |
| 1322 | + question="What is 2+2?", |
| 1323 | + answers=CHOICE_ANSWERS, |
| 1324 | + ).assessment_id |
| 1325 | + |
| 1326 | + item = self._get_item(assessment_id) |
| 1327 | + |
| 1328 | + self.assertIn('xml:lang="en"', item["raw_data"]) |
| 1329 | + |
| 1330 | + def test_perseus_question_returned_unchanged(self): |
| 1331 | + raw_data = '{"question": {"content": "raw perseus"}}' |
| 1332 | + assessment_id = self._create_item( |
| 1333 | + type=exercises.PERSEUS_QUESTION, raw_data=raw_data |
| 1334 | + ).assessment_id |
| 1335 | + |
| 1336 | + item = self._get_item(assessment_id) |
| 1337 | + |
| 1338 | + self.assertEqual(item["type"], exercises.PERSEUS_QUESTION) |
| 1339 | + self.assertEqual(item["raw_data"], raw_data) |
| 1340 | + # A passed-through row must shed the node language too. |
| 1341 | + self.assertNotIn("contentnode__language__lang_code", item) |
| 1342 | + |
| 1343 | + def test_native_qti_item_returned_unchanged(self): |
| 1344 | + assessment_id = self._create_item( |
| 1345 | + type=exercises.QTI, raw_data=VALID_CHOICE_ITEM |
| 1346 | + ).assessment_id |
| 1347 | + |
| 1348 | + item = self._get_item(assessment_id) |
| 1349 | + |
| 1350 | + self.assertEqual(item["type"], exercises.QTI) |
| 1351 | + self.assertEqual(item["raw_data"], VALID_CHOICE_ITEM) |
| 1352 | + |
| 1353 | + def test_detail_route_converts(self): |
| 1354 | + assessmentitem = self._create_item( |
| 1355 | + type=exercises.SINGLE_SELECTION, |
| 1356 | + question="What is 2+2?", |
| 1357 | + answers=CHOICE_ANSWERS, |
| 1358 | + ) |
| 1359 | + |
| 1360 | + response = self.client.get( |
| 1361 | + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) |
| 1362 | + ) |
| 1363 | + |
| 1364 | + self.assertEqual(response.status_code, 200, response.content) |
| 1365 | + self.assertEqual(response.json()["type"], exercises.QTI) |
| 1366 | + self.assertTrue(validate_qti_item(response.json()["raw_data"]).is_valid) |
| 1367 | + |
| 1368 | + def test_unconvertible_item_is_left_legacy_and_the_rest_convert(self): |
| 1369 | + unconvertible = self._create_item( |
| 1370 | + type="not_a_real_type", question="What is 2+2?" |
| 1371 | + ) |
| 1372 | + convertible = self._create_item( |
| 1373 | + type=exercises.SINGLE_SELECTION, |
| 1374 | + question="What is 3+3?", |
| 1375 | + answers=CHOICE_ANSWERS, |
| 1376 | + ) |
| 1377 | + |
| 1378 | + with self.assertLogs( |
| 1379 | + "contentcuration.viewsets.assessmentitem", level="ERROR" |
| 1380 | + ) as logs: |
| 1381 | + items = self._list_items(contentnode=self.node.id) |
| 1382 | + |
| 1383 | + self.assertIn(unconvertible.assessment_id, logs.output[0]) |
| 1384 | + self.assertEqual(items[unconvertible.assessment_id]["type"], "not_a_real_type") |
| 1385 | + self.assertEqual(items[unconvertible.assessment_id]["question"], "What is 2+2?") |
| 1386 | + self.assertEqual(items[convertible.assessment_id]["type"], exercises.QTI) |
| 1387 | + |
| 1388 | + def test_unconvertible_item_is_not_a_404_on_detail_route(self): |
| 1389 | + # Only this route goes through serialize_object(), which turns an error raised |
| 1390 | + # here into "No AssessmentItem matches the given query". |
| 1391 | + assessmentitem = self._create_item( |
| 1392 | + type="not_a_real_type", question="What is 2+2?" |
| 1393 | + ) |
| 1394 | + |
| 1395 | + response = self.client.get( |
| 1396 | + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) |
| 1397 | + ) |
| 1398 | + |
| 1399 | + self.assertEqual(response.status_code, 200, response.content) |
| 1400 | + self.assertEqual(response.json()["type"], "not_a_real_type") |
| 1401 | + |
| 1402 | + |
1180 | 1403 | class ContentIDTestCase(SyncTestMixin, StudioAPITestCase): |
1181 | 1404 | def setUp(self): |
1182 | 1405 | super(ContentIDTestCase, self).setUp() |
|
0 commit comments