3535from .testdata import slideshow
3636from .testdata import thumbnail_bytes
3737from .testdata import tree
38+ from .utils .qti .test_validation import VALID_CHOICE_ITEM
3839from .utils .restricted_filesystemstorage import RestrictedFileSystemStorage
3940from contentcuration import models as cc
4041from contentcuration .models import CustomTaskMetadata
@@ -258,6 +259,75 @@ def setUp(self):
258259 ai .contentnode = qti_exercise
259260 ai .save ()
260261
262+ # Native QTI item, no perseus_question present -> must route to QTI packaging
263+ native_qti_exercise = create_node (
264+ {
265+ "kind_id" : "exercise" ,
266+ "title" : "Native QTI Exercise" ,
267+ "extra_fields" : qti_extra_fields ,
268+ }
269+ )
270+ native_qti_exercise .complete = True
271+ native_qti_exercise .parent = current_exercise .parent
272+ native_qti_exercise .save ()
273+ cc .AssessmentItem .objects .create (
274+ contentnode = native_qti_exercise ,
275+ assessment_id = uuid .uuid4 ().hex ,
276+ type = exercises .QTI ,
277+ question = "" ,
278+ answers = "[]" ,
279+ hints = "[]" ,
280+ raw_data = VALID_CHOICE_ITEM ,
281+ order = 1 ,
282+ randomize = False ,
283+ )
284+
285+ # Only legacy structured-field items, no perseus_question -> must now route to QTI (was Perseus)
286+ legacy_no_perseus_exercise = create_node (
287+ {
288+ "kind_id" : "exercise" ,
289+ "title" : "Legacy No Perseus Exercise" ,
290+ "extra_fields" : qti_extra_fields ,
291+ }
292+ )
293+ legacy_no_perseus_exercise .complete = True
294+ legacy_no_perseus_exercise .parent = current_exercise .parent
295+ legacy_no_perseus_exercise .save ()
296+ cc .AssessmentItem .objects .create (
297+ contentnode = legacy_no_perseus_exercise ,
298+ assessment_id = uuid .uuid4 ().hex ,
299+ type = exercises .SINGLE_SELECTION ,
300+ question = "What is 2+2?" ,
301+ answers = json .dumps ([{"answer" : "4" , "correct" : True , "order" : 1 }]),
302+ hints = json .dumps ([]),
303+ raw_data = "{}" ,
304+ order = 1 ,
305+ randomize = False ,
306+ )
307+
308+ # A perseus_question item present -> must still route to Perseus packaging
309+ perseus_only_exercise = create_node (
310+ {
311+ "kind_id" : "exercise" ,
312+ "title" : "Perseus Only Exercise" ,
313+ "extra_fields" : qti_extra_fields ,
314+ }
315+ )
316+ perseus_only_exercise .complete = True
317+ perseus_only_exercise .parent = current_exercise .parent
318+ perseus_only_exercise .save ()
319+ cc .AssessmentItem .objects .create (
320+ contentnode = perseus_only_exercise ,
321+ assessment_id = uuid .uuid4 ().hex ,
322+ type = exercises .PERSEUS_QUESTION ,
323+ question = "" ,
324+ answers = "[]" ,
325+ hints = "[]" ,
326+ raw_data = "{}" ,
327+ order = 1 ,
328+ randomize = False ,
329+ )
330+
261331 first_topic = self .content_channel .main_tree .get_descendants ().first ()
262332
263333 # Add a publishable topic to ensure it does not inherit but that its children do
@@ -709,6 +779,21 @@ def test_qti_exercise_generates_qti_archive(self):
709779 "QTI file should be a zip archive" ,
710780 )
711781
782+ def test_native_qti_item_routes_to_qti_packaging (self ):
783+ node = cc .ContentNode .objects .get (title = "Native QTI Exercise" )
784+ self .assertTrue (node .files .filter (preset_id = format_presets .QTI_ZIP ).exists ())
785+ self .assertFalse (node .files .filter (preset_id = format_presets .EXERCISE ).exists ())
786+
787+ def test_legacy_items_without_perseus_question_route_to_qti_packaging (self ):
788+ node = cc .ContentNode .objects .get (title = "Legacy No Perseus Exercise" )
789+ self .assertTrue (node .files .filter (preset_id = format_presets .QTI_ZIP ).exists ())
790+ self .assertFalse (node .files .filter (preset_id = format_presets .EXERCISE ).exists ())
791+
792+ def test_perseus_question_item_routes_to_perseus_packaging (self ):
793+ node = cc .ContentNode .objects .get (title = "Perseus Only Exercise" )
794+ self .assertTrue (node .files .filter (preset_id = format_presets .EXERCISE ).exists ())
795+ self .assertFalse (node .files .filter (preset_id = format_presets .QTI_ZIP ).exists ())
796+
712797 def test_qti_archive_contains_manifest_and_assessment_ids (self ):
713798
714799 published_qti_exercise = kolibri_models .ContentNode .objects .get (
@@ -733,15 +818,17 @@ def test_unit_topic_publishes_with_exercise_zip(self):
733818 attached assessment items compiled into a zip file during publishing."""
734819 unit_topic = cc .ContentNode .objects .get (title = "Test Unit Topic" )
735820
736- # Assert UNIT topic has exercise file in Studio
821+ # Assert UNIT topic has a QTI archive file in Studio. Its assessment
822+ # items are legacy structured-field (SINGLE_SELECTION) with no
823+ # perseus_question item, so they route to QTI packaging.
737824 unit_files = cc .File .objects .filter (
738825 contentnode = unit_topic ,
739- preset_id = format_presets .EXERCISE ,
826+ preset_id = format_presets .QTI_ZIP ,
740827 )
741828 self .assertEqual (
742829 unit_files .count (),
743830 1 ,
744- "UNIT topic should have exactly one exercise archive file" ,
831+ "UNIT topic should have exactly one QTI archive file" ,
745832 )
746833
747834 # Assert NO assessment metadata in Kolibri export for UNIT topics
0 commit comments