Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
32 changes: 32 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from contentcuration.utils.assessment.qti.media import get_qti_media_references

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"}
168 changes: 168 additions & 0 deletions contentcuration/contentcuration/tests/utils/test_exercise_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from contentcuration.tests.base import StudioTestCase
from contentcuration.tests.testdata import fileobj_exercise_graphie
from contentcuration.tests.testdata import fileobj_exercise_image
from contentcuration.tests.utils.qti.test_validation import _item_xml
from contentcuration.tests.utils.qti.test_validation import VALID_CHOICE_ITEM
from contentcuration.utils.assessment.perseus import PerseusExerciseGenerator
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
Expand Down Expand Up @@ -1231,6 +1233,20 @@ class TestQTIExerciseCreation(StudioTestCase):

maxDiff = None

NATIVE_ITEM_XML = _item_xml(
"native_item_1",
"Native Item",
'<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-choice-interaction response-identifier="RESPONSE" max-choices="1" min-choices="0" '
'orientation="vertical"><qti-prompt>Pick one. '
'<img src="{checksum}.{ext}" alt="diagram" /></qti-prompt>'
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">A</qti-simple-choice>'
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">B</qti-simple-choice>'
"</qti-choice-interaction>",
)

def setUp(self):
self.setUpBase()

Expand Down Expand Up @@ -1278,6 +1294,20 @@ def _create_assessment_item(
)
return item

def _create_native_qti_item(self, raw_data, assessment_id=None):
"""Helper to create a native type=QTI assessment item with the given raw_data."""
return AssessmentItem.objects.create(
contentnode=self.exercise_node,
assessment_id=assessment_id or uuid4().hex,
type=exercises.QTI,
question="",
answers="[]",
hints="[]",
raw_data=raw_data,
order=len(self.exercise_node.assessment_items.all()) + 1,
randomize=False,
)

def _create_qti_zip(self, exercise_data):
"""Create QTI exercise zip using the generator"""
generator = QTIExerciseGenerator(
Expand Down Expand Up @@ -2107,3 +2137,141 @@ def test_input_question(self):
self._normalize_xml(expected_item_xml),
self._normalize_xml(actual_item_xml),
)

def test_native_qti_item_written_verbatim(self):
"""The item XML in the zip must byte-match the authored raw_data."""
raw_data = _item_xml(
"native_item_1",
"Native Item",
'<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-choice-interaction response-identifier="RESPONSE" max-choices="1" min-choices="0" '
'orientation="vertical"><qti-prompt>Pick one.</qti-prompt>'
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">A</qti-simple-choice>'
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">B</qti-simple-choice>'
"</qti-choice-interaction>",
)
item = self._create_native_qti_item(raw_data)
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.QTI},
}
self._create_qti_zip(exercise_data)
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
zip_file = self._validate_qti_zip_structure(exercise_file)
self.assertIn("items/native_item_1.xml", zip_file.namelist())
self.assertEqual(
zip_file.read("items/native_item_1.xml").decode("utf-8"), raw_data
)

def test_native_qti_item_media_included_and_addressed(self):
# fileobj_exercise_image() writes real bytes to storage keyed by their
# actual md5 checksum + "jpg" ext -- use that real checksum/ext rather
# than a hardcoded one, or _write_qti_media_files' storage.open() call
# finds nothing there (see test_exercise_with_image, same file).
image_file = fileobj_exercise_image()
raw_data = self.NATIVE_ITEM_XML.format(
checksum=image_file.checksum, ext=image_file.file_format_id
)
item = self._create_native_qti_item(raw_data)
image_file.assessment_item = item
image_file.save()
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.QTI},
}
self._create_qti_zip(exercise_data)
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
zip_file = self._validate_qti_zip_structure(exercise_file)
media_filename = f"{image_file.checksum}.{image_file.file_format_id}"
self.assertIn(f"items/{media_filename}", zip_file.namelist())
manifest = zip_file.read("imsmanifest.xml").decode("utf-8")
self.assertIn(media_filename, manifest)

def test_native_qti_item_invalid_raw_data_raises(self):
invalid_raw_data = VALID_CHOICE_ITEM.replace(
'orientation="vertical"', 'orientation="sideways"'
)
item = self._create_native_qti_item(invalid_raw_data)
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.QTI},
}
with self.assertRaises(ValueError):
self._create_qti_zip(exercise_data)
self.assertFalse(
self.exercise_node.files.filter(preset_id=format_presets.QTI_ZIP).exists()
)

def test_native_qti_item_missing_media_file_raises(self):
raw_data = self.NATIVE_ITEM_XML.format(checksum="b" * 32, ext="png")
item = self._create_native_qti_item(raw_data) # no File row linked
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.QTI},
}
with self.assertRaises(ValueError):
self._create_qti_zip(exercise_data)

def test_native_qti_duplicate_identifier_raises(self):
item1 = self._create_native_qti_item(VALID_CHOICE_ITEM)
item2 = self._create_native_qti_item(
VALID_CHOICE_ITEM
) # same "item_1" identifier
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item1.assessment_id, item2.assessment_id],
"assessment_mapping": {
item1.assessment_id: exercises.QTI,
item2.assessment_id: exercises.QTI,
},
}
with self.assertRaises(ValueError):
self._create_qti_zip(exercise_data)

def test_republish_replaces_stale_native_qti_archive(self):
item = self._create_native_qti_item(VALID_CHOICE_ITEM)
exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 5,
"m": 3,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.QTI},
}
self._create_qti_zip(exercise_data)
first_checksum = self.exercise_node.files.get(
preset_id=format_presets.QTI_ZIP
).checksum

item.raw_data = VALID_CHOICE_ITEM.replace("Sample Item", "Renamed Item")
item.save()
self._create_qti_zip(exercise_data)

self.assertEqual(
self.exercise_node.files.filter(preset_id=format_presets.QTI_ZIP).count(), 1
)
second_checksum = self.exercise_node.files.get(
preset_id=format_presets.QTI_ZIP
).checksum
self.assertNotEqual(first_checksum, second_checksum)
Loading
Loading