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
93 changes: 90 additions & 3 deletions contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .testdata import slideshow
from .testdata import thumbnail_bytes
from .testdata import tree
from .utils.qti.test_validation import VALID_CHOICE_ITEM
from .utils.restricted_filesystemstorage import RestrictedFileSystemStorage
from contentcuration import models as cc
from contentcuration.models import CustomTaskMetadata
Expand Down Expand Up @@ -258,6 +259,75 @@ def setUp(self):
ai.contentnode = qti_exercise
ai.save()

# Native QTI item, no perseus_question present -> must route to QTI packaging
native_qti_exercise = create_node(
{
"kind_id": "exercise",
"title": "Native QTI Exercise",
"extra_fields": qti_extra_fields,
}
)
native_qti_exercise.complete = True
native_qti_exercise.parent = current_exercise.parent
native_qti_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=native_qti_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.QTI,
question="",
answers="[]",
hints="[]",
raw_data=VALID_CHOICE_ITEM,
order=1,
randomize=False,
)

# Only legacy structured-field items, no perseus_question -> must now route to QTI (was Perseus)
legacy_no_perseus_exercise = create_node(
{
"kind_id": "exercise",
"title": "Legacy No Perseus Exercise",
"extra_fields": qti_extra_fields,
}
)
legacy_no_perseus_exercise.complete = True
legacy_no_perseus_exercise.parent = current_exercise.parent
legacy_no_perseus_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=legacy_no_perseus_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.SINGLE_SELECTION,
question="What is 2+2?",
answers=json.dumps([{"answer": "4", "correct": True, "order": 1}]),
hints=json.dumps([]),
raw_data="{}",
order=1,
randomize=False,
)

# A perseus_question item present -> must still route to Perseus packaging
perseus_only_exercise = create_node(
{
"kind_id": "exercise",
"title": "Perseus Only Exercise",
"extra_fields": qti_extra_fields,
}
)
perseus_only_exercise.complete = True
perseus_only_exercise.parent = current_exercise.parent
perseus_only_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=perseus_only_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.PERSEUS_QUESTION,
question="",
answers="[]",
hints="[]",
raw_data="{}",
order=1,
randomize=False,
)

first_topic = self.content_channel.main_tree.get_descendants().first()

# Add a publishable topic to ensure it does not inherit but that its children do
Expand Down Expand Up @@ -709,6 +779,21 @@ def test_qti_exercise_generates_qti_archive(self):
"QTI file should be a zip archive",
)

def test_native_qti_item_routes_to_qti_packaging(self):
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())

def test_legacy_items_without_perseus_question_route_to_qti_packaging(self):
node = cc.ContentNode.objects.get(title="Legacy No Perseus Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())

def test_perseus_question_item_routes_to_perseus_packaging(self):
node = cc.ContentNode.objects.get(title="Perseus Only Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())

def test_qti_archive_contains_manifest_and_assessment_ids(self):

published_qti_exercise = kolibri_models.ContentNode.objects.get(
Expand All @@ -733,15 +818,17 @@ def test_unit_topic_publishes_with_exercise_zip(self):
attached assessment items compiled into a zip file during publishing."""
unit_topic = cc.ContentNode.objects.get(title="Test Unit Topic")

# Assert UNIT topic has exercise file in Studio
# Assert UNIT topic has a QTI archive file in Studio. Its assessment
# items are legacy structured-field (SINGLE_SELECTION) with no
# perseus_question item, so they route to QTI packaging.
unit_files = cc.File.objects.filter(
contentnode=unit_topic,
preset_id=format_presets.EXERCISE,
preset_id=format_presets.QTI_ZIP,
)
self.assertEqual(
unit_files.count(),
1,
"UNIT topic should have exactly one exercise archive file",
"UNIT topic should have exactly one QTI archive file",
)

# Assert NO assessment metadata in Kolibri export for UNIT topics
Expand Down
82 changes: 82 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from contentcuration.utils.assessment.qti.media import get_qti_media_references
from contentcuration.utils.assessment.qti.media import rewrite_qti_media_paths

CHECKSUM_A = "a" * 32
CHECKSUM_B = "b" * 32


def test_extracts_checksum_from_src_href_data():
xml = (
f'<item><img src="{CHECKSUM_A}.png"/>'
f'<a href="{CHECKSUM_B}.pdf">x</a>'
f'<object data="{CHECKSUM_A}.png"></object></item>'
)
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png", f"{CHECKSUM_B}.pdf"}


def test_extracts_checksums_from_srcset():
xml = f'<item><img srcset="{CHECKSUM_A}.png 1x, {CHECKSUM_B}.png 2x"/></item>'
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png", f"{CHECKSUM_B}.png"}


def test_ignores_non_checksum_values():
xml = '<item><img src="https://example.com/x.png"/><a href="notachecksum.png">x</a></item>'
assert get_qti_media_references(xml) == set()


def test_returns_empty_set_for_malformed_xml():
assert get_qti_media_references("<item><unclosed>") == set()


def test_accepts_bytes():
xml = f'<item><img src="{CHECKSUM_A}.png"/></item>'.encode("utf-8")
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png"}


def test_rewrite_leaves_input_untouched_with_no_mapping():
xml = f'<item><img src="{CHECKSUM_A}.png" /></item>'
assert rewrite_qti_media_paths(xml, {}) == xml


def test_rewrite_remaps_src_href_data_and_preserves_formatting():
xml = (
f'<item><img src="{CHECKSUM_A}.png" alt="diagram" />'
f'<a href="{CHECKSUM_B}.pdf">x</a>'
f'<object data="{CHECKSUM_A}.png"></object></item>'
)
result = rewrite_qti_media_paths(
xml,
{
f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png",
f"{CHECKSUM_B}.pdf": f"images/{CHECKSUM_B}.pdf",
},
)
assert result == (
f'<item><img src="images/{CHECKSUM_A}.png" alt="diagram" />'
f'<a href="images/{CHECKSUM_B}.pdf">x</a>'
f'<object data="images/{CHECKSUM_A}.png"></object></item>'
)


def test_rewrite_remaps_srcset_entries_preserving_descriptors():
xml = f'<item><img srcset="{CHECKSUM_A}.png 1x, {CHECKSUM_B}.png 2x"/></item>'
result = rewrite_qti_media_paths(
xml,
{
f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png",
f"{CHECKSUM_B}.png": f"images/{CHECKSUM_B}.png",
},
)
assert result == (
f'<item><img srcset="images/{CHECKSUM_A}.png 1x, images/{CHECKSUM_B}.png 2x"/></item>'
)


def test_rewrite_ignores_values_not_in_mapping():
xml = f'<item><img src="{CHECKSUM_A}.png"/><a href="{CHECKSUM_B}.pdf">x</a></item>'
result = rewrite_qti_media_paths(
xml, {f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png"}
)
assert result == (
f'<item><img src="images/{CHECKSUM_A}.png"/><a href="{CHECKSUM_B}.pdf">x</a></item>'
)
Loading
Loading