@@ -377,6 +377,42 @@ def setUp(self):
377377 randomize = False ,
378378 )
379379
380+ # A node mixing a native QTI item with a raw perseus_question item ->
381+ # must route to a single QTI package (Perseus embedded as custom
382+ # interactions), not a separate Perseus archive.
383+ mixed_perseus_qti_exercise = create_node (
384+ {
385+ "kind_id" : "exercise" ,
386+ "title" : "Perseus + Native QTI Mixed Exercise" ,
387+ "extra_fields" : qti_extra_fields ,
388+ }
389+ )
390+ mixed_perseus_qti_exercise .complete = True
391+ mixed_perseus_qti_exercise .parent = current_exercise .parent
392+ mixed_perseus_qti_exercise .save ()
393+ cc .AssessmentItem .objects .create (
394+ contentnode = mixed_perseus_qti_exercise ,
395+ assessment_id = uuid .uuid4 ().hex ,
396+ type = exercises .QTI ,
397+ question = "" ,
398+ answers = "[]" ,
399+ hints = "[]" ,
400+ raw_data = VALID_CHOICE_ITEM ,
401+ order = 1 ,
402+ randomize = False ,
403+ )
404+ cc .AssessmentItem .objects .create (
405+ contentnode = mixed_perseus_qti_exercise ,
406+ assessment_id = uuid .uuid4 ().hex ,
407+ type = exercises .PERSEUS_QUESTION ,
408+ question = "" ,
409+ answers = "[]" ,
410+ hints = "[]" ,
411+ raw_data = "{}" ,
412+ order = 2 ,
413+ randomize = False ,
414+ )
415+
380416 first_topic = self .content_channel .main_tree .get_descendants ().first ()
381417
382418 # Add a publishable topic to ensure it does not inherit but that its children do
@@ -671,12 +707,19 @@ def test_localfile_large_file_size_bigint(self):
671707 self .assertIsNone (local_file .file_size )
672708
673709 def test_file_included_presets_renderable (self ):
674- # Every non-supplementary (renderable) exported file carries its own preset bit.
710+ # Every non-supplementary (renderable) exported file carries its own
711+ # preset bit. A mixed Perseus + native QTI package additionally sets the
712+ # exercise bit (see test_mixed_qti_file_included_presets); no other file
713+ # is augmented.
675714 files = kolibri_models .File .objects .filter (supplementary = False )
676715 assert files .count () > 0
716+ exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER .index (format_presets .EXERCISE )
677717 for file in files :
678- expected = 2 ** RENDERABLE_PRESETS_ORDER .index (file .preset )
679- self .assertEqual (file .included_presets , expected )
718+ own_bit = 2 ** RENDERABLE_PRESETS_ORDER .index (file .preset )
719+ if file .preset == format_presets .QTI_ZIP :
720+ self .assertIn (file .included_presets , (own_bit , own_bit | exercise_bit ))
721+ else :
722+ self .assertEqual (file .included_presets , own_bit )
680723
681724 def test_file_included_presets_supplementary_null (self ):
682725 # Supplementary files (e.g. thumbnails) leave included_presets NULL.
@@ -685,6 +728,31 @@ def test_file_included_presets_supplementary_null(self):
685728 for file in files :
686729 self .assertIsNone (file .included_presets )
687730
731+ def test_mixed_qti_file_included_presets (self ):
732+ # A mixed Perseus + native QTI package embeds raw Perseus questions as
733+ # custom interactions, so its qti File must also flag the exercise
734+ # (Perseus) renderer via included_presets = qti | exercise.
735+ qti_bit = 2 ** RENDERABLE_PRESETS_ORDER .index (format_presets .QTI_ZIP )
736+ exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER .index (format_presets .EXERCISE )
737+
738+ mixed_node = kolibri_models .ContentNode .objects .get (
739+ title = "Perseus + Native QTI Mixed Exercise"
740+ )
741+ mixed_qti_file = kolibri_models .File .objects .get (
742+ contentnode = mixed_node , preset = format_presets .QTI_ZIP
743+ )
744+ self .assertEqual (mixed_qti_file .included_presets , qti_bit | exercise_bit )
745+
746+ # A native-QTI-only node embeds no Perseus questions, so its qti File
747+ # keeps only the qti bit (guards against over-tagging).
748+ native_node = kolibri_models .ContentNode .objects .get (
749+ title = "Native QTI Exercise"
750+ )
751+ native_qti_file = kolibri_models .File .objects .get (
752+ contentnode = native_node , preset = format_presets .QTI_ZIP
753+ )
754+ self .assertEqual (native_qti_file .included_presets , qti_bit )
755+
688756 def test_channel_icon_encoding (self ):
689757 self .assertIsNotNone (self .content_channel .icon_encoding )
690758
@@ -932,6 +1000,11 @@ def test_perseus_question_item_routes_to_perseus_packaging(self):
9321000 self .assertTrue (node .files .filter (preset_id = format_presets .EXERCISE ).exists ())
9331001 self .assertFalse (node .files .filter (preset_id = format_presets .QTI_ZIP ).exists ())
9341002
1003+ def test_mixed_perseus_and_native_qti_routes_to_qti (self ):
1004+ node = cc .ContentNode .objects .get (title = "Perseus + Native QTI Mixed Exercise" )
1005+ self .assertTrue (node .files .filter (preset_id = format_presets .QTI_ZIP ).exists ())
1006+ self .assertFalse (node .files .filter (preset_id = format_presets .EXERCISE ).exists ())
1007+
9351008 def test_qti_archive_contains_manifest_and_assessment_ids (self ):
9361009
9371010 published_qti_exercise = kolibri_models .ContentNode .objects .get (
0 commit comments