|
7 | 7 | import re |
8 | 8 | import zipfile |
9 | 9 | from io import BytesIO |
| 10 | +from tempfile import TemporaryDirectory |
10 | 11 | from uuid import uuid4 |
11 | 12 |
|
12 | 13 | from django.core.files.storage import default_storage as storage |
|
27 | 28 | from contentcuration.utils.assessment.qti.archive import hex_to_qti_id |
28 | 29 | from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator |
29 | 30 | from contentcuration.utils.assessment.qti.validation import parse_qti_xml |
| 31 | +from contentcuration.utils.assessment.qti.validation import validate_qti_item |
30 | 32 |
|
31 | 33 |
|
32 | 34 | class TestPerseusExerciseCreation(StudioTestCase): |
@@ -480,6 +482,45 @@ def _create_perseus_item(self): |
480 | 482 |
|
481 | 483 | return item, graphie_files |
482 | 484 |
|
| 485 | + def test_write_raw_perseus_assets_returns_paths_and_writes_files(self): |
| 486 | + """`_write_raw_perseus_assets` writes an item's images/graphie assets into |
| 487 | + the given directory and returns their package-relative paths.""" |
| 488 | + image_file = fileobj_exercise_image() |
| 489 | + graphie_file = fileobj_exercise_graphie(original_filename="mygraphie") |
| 490 | + |
| 491 | + item = AssessmentItem.objects.create( |
| 492 | + contentnode=self.exercise_node, |
| 493 | + assessment_id="fedcba0987654321fedcba0987654321", |
| 494 | + type=exercises.PERSEUS_QUESTION, |
| 495 | + raw_data="{}", |
| 496 | + order=1, |
| 497 | + randomize=True, |
| 498 | + ) |
| 499 | + image_file.assessment_item = item |
| 500 | + image_file.save() |
| 501 | + graphie_file.assessment_item = item |
| 502 | + graphie_file.save() |
| 503 | + |
| 504 | + generator = PerseusExerciseGenerator( |
| 505 | + self.exercise_node, {}, self.channel.id, "en-US", user_id=self.user.id |
| 506 | + ) |
| 507 | + with TemporaryDirectory() as tempdir: |
| 508 | + generator.tempdir = tempdir |
| 509 | + written = generator._write_raw_perseus_assets(item, "perseus/images") |
| 510 | + |
| 511 | + image_path = ( |
| 512 | + f"perseus/images/{image_file.checksum}.{image_file.file_format_id}" |
| 513 | + ) |
| 514 | + svg_path = f"perseus/images/{graphie_file.original_filename}.svg" |
| 515 | + json_path = f"perseus/images/{graphie_file.original_filename}-data.json" |
| 516 | + |
| 517 | + self.assertIn(image_path, written) |
| 518 | + self.assertIn(svg_path, written) |
| 519 | + self.assertIn(json_path, written) |
| 520 | + |
| 521 | + for path in (image_path, svg_path, json_path): |
| 522 | + self.assertTrue(os.path.exists(os.path.join(tempdir, path))) |
| 523 | + |
483 | 524 | def test_exercise_with_graphie(self): |
484 | 525 | """Test creating an exercise with graphie files (SVG+JSON pairs)""" |
485 | 526 |
|
@@ -1454,32 +1495,83 @@ def test_qti_exercise_without_hints_produces_no_catalog_info(self): |
1454 | 1495 | item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", []) |
1455 | 1496 | self.assertNotIn("<qti-catalog-info", item_xml) |
1456 | 1497 |
|
1457 | | - def test_perseus_question_rejection(self): |
1458 | | - """Test that Perseus questions are properly rejected""" |
1459 | | - assessment_id = "aaaa1111bbbb2222cccc3333dddd4444" |
1460 | | - # Create a mock Perseus question |
1461 | | - item = AssessmentItem.objects.create( |
| 1498 | + def test_perseus_custom_interaction_embedded_with_native_qti(self): |
| 1499 | + """A node mixing a native QTI item and a raw Perseus question yields one |
| 1500 | + QTI package: the native item, plus the Perseus question wrapped as a |
| 1501 | + ``qti-custom-interaction`` with its JSON and image packaged and declared.""" |
| 1502 | + native_id = "1234567890abcdef1234567890abcdef" |
| 1503 | + native_item = self._create_native_qti_item( |
| 1504 | + VALID_CHOICE_ITEM, assessment_id=native_id |
| 1505 | + ) |
| 1506 | + |
| 1507 | + image_file = fileobj_exercise_image() |
| 1508 | + image_url = exercises.CONTENT_STORAGE_FORMAT.format(image_file.filename()) |
| 1509 | + perseus_id = "aaaa1111bbbb2222cccc3333dddd4444" |
| 1510 | + perseus_item = AssessmentItem.objects.create( |
1462 | 1511 | contentnode=self.exercise_node, |
1463 | | - assessment_id=assessment_id, |
| 1512 | + assessment_id=perseus_id, |
1464 | 1513 | type=exercises.PERSEUS_QUESTION, |
1465 | | - raw_data='{"question": {"content": "Perseus content"}}', |
1466 | | - order=1, |
| 1514 | + raw_data=json.dumps( |
| 1515 | + {"question": {"content": f"See ", "images": {}}}, |
| 1516 | + ensure_ascii=False, |
| 1517 | + ), |
| 1518 | + order=2, |
| 1519 | + randomize=False, |
1467 | 1520 | ) |
| 1521 | + image_file.assessment_item = perseus_item |
| 1522 | + image_file.save() |
1468 | 1523 |
|
1469 | 1524 | exercise_data = { |
1470 | 1525 | "mastery_model": exercises.M_OF_N, |
1471 | 1526 | "randomize": True, |
1472 | 1527 | "n": 1, |
1473 | 1528 | "m": 1, |
1474 | | - "all_assessment_items": [item.assessment_id], |
1475 | | - "assessment_mapping": {item.assessment_id: exercises.PERSEUS_QUESTION}, |
| 1529 | + "all_assessment_items": [ |
| 1530 | + native_item.assessment_id, |
| 1531 | + perseus_item.assessment_id, |
| 1532 | + ], |
| 1533 | + "assessment_mapping": { |
| 1534 | + native_item.assessment_id: exercises.QTI, |
| 1535 | + perseus_item.assessment_id: exercises.PERSEUS_QUESTION, |
| 1536 | + }, |
1476 | 1537 | } |
1477 | 1538 |
|
1478 | | - # Should raise ValueError for Perseus questions |
1479 | | - with self.assertRaises(ValueError) as context: |
1480 | | - self._create_qti_zip(exercise_data) |
| 1539 | + self._create_qti_zip(exercise_data) |
| 1540 | + exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) |
| 1541 | + zip_file = self._validate_qti_zip_structure(exercise_file) |
| 1542 | + |
| 1543 | + namelist = zip_file.namelist() |
| 1544 | + perseus_item_path = f"items/{hex_to_qti_id(perseus_id)}.xml" |
| 1545 | + perseus_json_path = f"perseus/{perseus_id}.json" |
| 1546 | + image_path = f"perseus/images/{image_file.filename()}" |
| 1547 | + |
| 1548 | + # Native QTI item and the Perseus wrapper item both present. |
| 1549 | + self.assertIn("items/item_1.xml", namelist) |
| 1550 | + self.assertIn(perseus_item_path, namelist) |
| 1551 | + # Perseus JSON and its image packaged. |
| 1552 | + self.assertIn(perseus_json_path, namelist) |
| 1553 | + self.assertIn(image_path, namelist) |
| 1554 | + |
| 1555 | + # The wrapper item validates and references the Perseus JSON. |
| 1556 | + wrapper_xml = zip_file.read(perseus_item_path).decode("utf-8") |
| 1557 | + self.assertTrue(validate_qti_item(wrapper_xml.encode("utf-8")).is_valid) |
| 1558 | + parsed = parse_qti_xml(wrapper_xml.encode("utf-8")) |
| 1559 | + custom = parsed.getroot().iter("{*}qti-custom-interaction") |
| 1560 | + custom_el = next(custom) |
| 1561 | + self.assertEqual(custom_el.get("data-type"), "perseus") |
| 1562 | + self.assertEqual(custom_el.get("data-perseus-path"), perseus_json_path) |
| 1563 | + |
| 1564 | + # The Perseus JSON's image reference was rewritten to the packaged path. |
| 1565 | + packaged_json = zip_file.read(perseus_json_path).decode("utf-8") |
| 1566 | + self.assertIn( |
| 1567 | + f"${exercises.IMG_PLACEHOLDER}/perseus/images/{image_file.filename()}", |
| 1568 | + packaged_json, |
| 1569 | + ) |
1481 | 1570 |
|
1482 | | - self.assertIn("Perseus questions are not supported", str(context.exception)) |
| 1571 | + # The manifest lists the JSON and image as files of the perseus resource. |
| 1572 | + manifest_xml = zip_file.read("imsmanifest.xml").decode("utf-8") |
| 1573 | + self.assertIn(f'<file href="{perseus_json_path}" />', manifest_xml) |
| 1574 | + self.assertIn(f'<file href="{image_path}" />', manifest_xml) |
1483 | 1575 |
|
1484 | 1576 | def test_exercise_with_image(self): |
1485 | 1577 | """Test QTI exercise generation with images""" |
|
0 commit comments