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
79 changes: 76 additions & 3 deletions contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,42 @@ def setUp(self):
randomize=False,
)

# A node mixing a native QTI item with a raw perseus_question item ->
# must route to a single QTI package (Perseus embedded as custom
# interactions), not a separate Perseus archive.
mixed_perseus_qti_exercise = create_node(
{
"kind_id": "exercise",
"title": "Perseus + Native QTI Mixed Exercise",
"extra_fields": qti_extra_fields,
}
)
mixed_perseus_qti_exercise.complete = True
mixed_perseus_qti_exercise.parent = current_exercise.parent
mixed_perseus_qti_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=mixed_perseus_qti_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.QTI,
question="",
answers="[]",
hints="[]",
raw_data=VALID_CHOICE_ITEM,
order=1,
randomize=False,
)
cc.AssessmentItem.objects.create(
contentnode=mixed_perseus_qti_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.PERSEUS_QUESTION,
question="",
answers="[]",
hints="[]",
raw_data="{}",
order=2,
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 @@ -671,12 +707,19 @@ def test_localfile_large_file_size_bigint(self):
self.assertIsNone(local_file.file_size)

def test_file_included_presets_renderable(self):
# Every non-supplementary (renderable) exported file carries its own preset bit.
# Every non-supplementary (renderable) exported file carries its own
# preset bit. A mixed Perseus + native QTI package additionally sets the
# exercise bit (see test_mixed_qti_file_included_presets); no other file
# is augmented.
files = kolibri_models.File.objects.filter(supplementary=False)
assert files.count() > 0
exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)
for file in files:
expected = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
self.assertEqual(file.included_presets, expected)
own_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
if file.preset == format_presets.QTI_ZIP:
self.assertIn(file.included_presets, (own_bit, own_bit | exercise_bit))
else:
self.assertEqual(file.included_presets, own_bit)

def test_file_included_presets_supplementary_null(self):
# Supplementary files (e.g. thumbnails) leave included_presets NULL.
Expand All @@ -685,6 +728,31 @@ def test_file_included_presets_supplementary_null(self):
for file in files:
self.assertIsNone(file.included_presets)

def test_mixed_qti_file_included_presets(self):
# A mixed Perseus + native QTI package embeds raw Perseus questions as
# custom interactions, so its qti File must also flag the exercise
# (Perseus) renderer via included_presets = qti | exercise.
qti_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.QTI_ZIP)
exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)

mixed_node = kolibri_models.ContentNode.objects.get(
title="Perseus + Native QTI Mixed Exercise"
)
mixed_qti_file = kolibri_models.File.objects.get(
contentnode=mixed_node, preset=format_presets.QTI_ZIP
)
self.assertEqual(mixed_qti_file.included_presets, qti_bit | exercise_bit)

# A native-QTI-only node embeds no Perseus questions, so its qti File
# keeps only the qti bit (guards against over-tagging).
native_node = kolibri_models.ContentNode.objects.get(
title="Native QTI Exercise"
)
native_qti_file = kolibri_models.File.objects.get(
contentnode=native_node, preset=format_presets.QTI_ZIP
)
self.assertEqual(native_qti_file.included_presets, qti_bit)

def test_channel_icon_encoding(self):
self.assertIsNotNone(self.content_channel.icon_encoding)

Expand Down Expand Up @@ -932,6 +1000,11 @@ def test_perseus_question_item_routes_to_perseus_packaging(self):
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_mixed_perseus_and_native_qti_routes_to_qti(self):
node = cc.ContentNode.objects.get(title="Perseus + Native QTI Mixed 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_qti_archive_contains_manifest_and_assessment_ids(self):

published_qti_exercise = kolibri_models.ContentNode.objects.get(
Expand Down
66 changes: 66 additions & 0 deletions contentcuration/contentcuration/tests/utils/qti/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@

from le_utils.constants import exercises

from contentcuration.utils.assessment.qti.convert import (
build_perseus_custom_interaction_item,
)
from contentcuration.utils.assessment.qti.convert import (
convert_legacy_assessment_item_to_qti,
)
from contentcuration.utils.assessment.qti.convert import hex_to_qti_id
from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem
from contentcuration.utils.assessment.qti.interaction_types.custom import (
CustomInteraction,
)
from contentcuration.utils.assessment.qti.validation import validate_qti_item


Expand Down Expand Up @@ -232,6 +239,65 @@ def test_free_response_with_maths(self):
)


class CustomInteractionTests(unittest.TestCase):
ASSESSMENT_ID = "2b1c3d4e5f60718293a4b5c6d7e8f900"

def _build_item(self):
return build_perseus_custom_interaction_item(
self.ASSESSMENT_ID,
f"perseus/{self.ASSESSMENT_ID}.json",
"Q 1",
"en",
)

def test_custom_interaction_element_and_attributes(self):
interaction = CustomInteraction(
response_identifier="RESPONSE",
data_type="perseus",
data_perseus_path="perseus/abc.json",
)

xml = interaction.to_xml_string()

self.assertEqual(
_normalize_xml(
'<qti-custom-interaction response-identifier="RESPONSE" '
'data-type="perseus" data-perseus-path="perseus/abc.json" />'
),
_normalize_xml(xml),
)

def test_builder_identifier_and_validity(self):
result = self._build_item()

self.assertEqual(result.identifier, hex_to_qti_id(self.ASSESSMENT_ID))
self.assertEqual(result.file_dependencies, [])
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
self.assertIn('data-type="perseus"', result.xml)
self.assertIn(
f'data-perseus-path="perseus/{self.ASSESSMENT_ID}.json"', result.xml
)

def test_builder_grades_from_record_correct_field(self):
"""
The Perseus renderer reports its result through a record RESPONSE, and
the item grades itself off that record's ``correct`` field.
"""
result = self._build_item()

normalized = _normalize_xml(result.xml)
# RESPONSE is a record so it can carry correct/simpleAnswer/answerState.
self.assertIn(
'<qti-response-declaration identifier="RESPONSE" cardinality="record"',
normalized,
)
# SCORE outcome plus response processing that reads the correct field.
self.assertIn('<qti-outcome-declaration identifier="SCORE"', normalized)
self.assertIn('<qti-field-value field-identifier="correct">', normalized)
self.assertIn('<qti-variable identifier="RESPONSE"', normalized)
self.assertIn('<qti-set-outcome-value identifier="SCORE">', normalized)


class UnsupportedTypeConversionTests(unittest.TestCase):
def test_unsupported_type_raises(self):
item = _make_item(
Expand Down
120 changes: 106 additions & 14 deletions contentcuration/contentcuration/tests/utils/test_exercise_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import re
import zipfile
from io import BytesIO
from tempfile import TemporaryDirectory
from uuid import uuid4

from django.core.files.storage import default_storage as storage
Expand All @@ -27,6 +28,7 @@
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
from contentcuration.utils.assessment.qti.validation import parse_qti_xml
from contentcuration.utils.assessment.qti.validation import validate_qti_item


class TestPerseusExerciseCreation(StudioTestCase):
Expand Down Expand Up @@ -480,6 +482,45 @@ def _create_perseus_item(self):

return item, graphie_files

def test_write_raw_perseus_assets_returns_paths_and_writes_files(self):
"""`_write_raw_perseus_assets` writes an item's images/graphie assets into
the given directory and returns their package-relative paths."""
image_file = fileobj_exercise_image()
graphie_file = fileobj_exercise_graphie(original_filename="mygraphie")

item = AssessmentItem.objects.create(
contentnode=self.exercise_node,
assessment_id="fedcba0987654321fedcba0987654321",
type=exercises.PERSEUS_QUESTION,
raw_data="{}",
order=1,
randomize=True,
)
image_file.assessment_item = item
image_file.save()
graphie_file.assessment_item = item
graphie_file.save()

generator = PerseusExerciseGenerator(
self.exercise_node, {}, self.channel.id, "en-US", user_id=self.user.id
)
with TemporaryDirectory() as tempdir:
generator.tempdir = tempdir
written = generator._write_raw_perseus_assets(item, "perseus/images")

image_path = (
f"perseus/images/{image_file.checksum}.{image_file.file_format_id}"
)
svg_path = f"perseus/images/{graphie_file.original_filename}.svg"
json_path = f"perseus/images/{graphie_file.original_filename}-data.json"

self.assertIn(image_path, written)
self.assertIn(svg_path, written)
self.assertIn(json_path, written)

for path in (image_path, svg_path, json_path):
self.assertTrue(os.path.exists(os.path.join(tempdir, path)))

def test_exercise_with_graphie(self):
"""Test creating an exercise with graphie files (SVG+JSON pairs)"""

Expand Down Expand Up @@ -1454,32 +1495,83 @@ def test_qti_exercise_without_hints_produces_no_catalog_info(self):
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
self.assertNotIn("<qti-catalog-info", item_xml)

def test_perseus_question_rejection(self):
"""Test that Perseus questions are properly rejected"""
assessment_id = "aaaa1111bbbb2222cccc3333dddd4444"
# Create a mock Perseus question
item = AssessmentItem.objects.create(
def test_perseus_custom_interaction_embedded_with_native_qti(self):
"""A node mixing a native QTI item and a raw Perseus question yields one
QTI package: the native item, plus the Perseus question wrapped as a
``qti-custom-interaction`` with its JSON and image packaged and declared."""
native_id = "1234567890abcdef1234567890abcdef"
native_item = self._create_native_qti_item(
VALID_CHOICE_ITEM, assessment_id=native_id
)

image_file = fileobj_exercise_image()
image_url = exercises.CONTENT_STORAGE_FORMAT.format(image_file.filename())
perseus_id = "aaaa1111bbbb2222cccc3333dddd4444"
perseus_item = AssessmentItem.objects.create(
contentnode=self.exercise_node,
assessment_id=assessment_id,
assessment_id=perseus_id,
type=exercises.PERSEUS_QUESTION,
raw_data='{"question": {"content": "Perseus content"}}',
order=1,
raw_data=json.dumps(
{"question": {"content": f"See ![shape]({image_url})", "images": {}}},
ensure_ascii=False,
),
order=2,
randomize=False,
)
image_file.assessment_item = perseus_item
image_file.save()

exercise_data = {
"mastery_model": exercises.M_OF_N,
"randomize": True,
"n": 1,
"m": 1,
"all_assessment_items": [item.assessment_id],
"assessment_mapping": {item.assessment_id: exercises.PERSEUS_QUESTION},
"all_assessment_items": [
native_item.assessment_id,
perseus_item.assessment_id,
],
"assessment_mapping": {
native_item.assessment_id: exercises.QTI,
perseus_item.assessment_id: exercises.PERSEUS_QUESTION,
},
}

# Should raise ValueError for Perseus questions
with self.assertRaises(ValueError) as context:
self._create_qti_zip(exercise_data)
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)

namelist = zip_file.namelist()
perseus_item_path = f"items/{hex_to_qti_id(perseus_id)}.xml"
perseus_json_path = f"perseus/{perseus_id}.json"
image_path = f"perseus/images/{image_file.filename()}"

# Native QTI item and the Perseus wrapper item both present.
self.assertIn("items/item_1.xml", namelist)
self.assertIn(perseus_item_path, namelist)
# Perseus JSON and its image packaged.
self.assertIn(perseus_json_path, namelist)
self.assertIn(image_path, namelist)

# The wrapper item validates and references the Perseus JSON.
wrapper_xml = zip_file.read(perseus_item_path).decode("utf-8")
self.assertTrue(validate_qti_item(wrapper_xml.encode("utf-8")).is_valid)
parsed = parse_qti_xml(wrapper_xml.encode("utf-8"))
custom = parsed.getroot().iter("{*}qti-custom-interaction")
custom_el = next(custom)
self.assertEqual(custom_el.get("data-type"), "perseus")
self.assertEqual(custom_el.get("data-perseus-path"), perseus_json_path)

# The Perseus JSON's image reference was rewritten to the packaged path.
packaged_json = zip_file.read(perseus_json_path).decode("utf-8")
self.assertIn(
f"${exercises.IMG_PLACEHOLDER}/perseus/images/{image_file.filename()}",
packaged_json,
)

self.assertIn("Perseus questions are not supported", str(context.exception))
# The manifest lists the JSON and image as files of the perseus resource.
manifest_xml = zip_file.read("imsmanifest.xml").decode("utf-8")
self.assertIn(f'<file href="{perseus_json_path}" />', manifest_xml)
self.assertIn(f'<file href="{image_path}" />', manifest_xml)

def test_exercise_with_image(self):
"""Test QTI exercise generation with images"""
Expand Down
Loading
Loading