diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py
index 8fd3b03e7a..c054a104c8 100644
--- a/contentcuration/contentcuration/tests/test_exportchannel.py
+++ b/contentcuration/contentcuration/tests/test_exportchannel.py
@@ -4,6 +4,7 @@
import string
import tempfile
import uuid
+import zipfile
from unittest import mock
import pytest
@@ -35,6 +36,7 @@
from .testdata import slideshow
from .testdata import thumbnail_bytes
from .testdata import tree
+from .utils.qti.test_validation import _item_xml
from .utils.qti.test_validation import VALID_CHOICE_ITEM
from .utils.restricted_filesystemstorage import RestrictedFileSystemStorage
from contentcuration import models as cc
@@ -56,6 +58,24 @@
pytestmark = pytest.mark.django_db
+# A schema-valid native QTI item whose single interaction (order) Perseus cannot
+# express, so a node containing it must publish QTI only.
+UNSUPPORTED_QTI_ITEM = _item_xml(
+ "item_unsupported",
+ "Unsupported Item",
+ ''
+ ""
+ "choice_0"
+ "choice_1"
+ ""
+ "",
+ ''
+ "Put these in order."
+ 'First'
+ 'Second'
+ "",
+)
+
def description():
return "".join(random.sample(string.printable, 20))
@@ -282,6 +302,29 @@ def setUp(self):
randomize=False,
)
+ # Native QTI item whose interaction Perseus cannot express -> QTI only
+ native_qti_unsupported_exercise = create_node(
+ {
+ "kind_id": "exercise",
+ "title": "Native QTI Unsupported Exercise",
+ "extra_fields": qti_extra_fields,
+ }
+ )
+ native_qti_unsupported_exercise.complete = True
+ native_qti_unsupported_exercise.parent = current_exercise.parent
+ native_qti_unsupported_exercise.save()
+ cc.AssessmentItem.objects.create(
+ contentnode=native_qti_unsupported_exercise,
+ assessment_id=uuid.uuid4().hex,
+ type=exercises.QTI,
+ question="",
+ answers="[]",
+ hints="[]",
+ raw_data=UNSUPPORTED_QTI_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(
{
@@ -779,8 +822,36 @@ 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):
+ def test_native_qti_choice_item_publishes_both_archives(self):
+ node = cc.ContentNode.objects.get(title="Native QTI Exercise")
+ self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
+ self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
+
+ def test_native_qti_perseus_ids_match_assessment_metadata(self):
+ """The derived Perseus item JSON filenames must equal the ids recorded
+ in the published node's ``AssessmentMetaData.assessment_item_ids`` (the
+ QTI manifest ``K``-ids), so older Kolibri resolves the derived items."""
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
+ exercise_file = node.files.get(preset_id=format_presets.EXERCISE)
+ with exercise_file.file_on_disk.open("rb") as file_handle:
+ item_stems = {
+ name[: -len(".json")]
+ for name in zipfile.ZipFile(file_handle).namelist()
+ if name.endswith(".json") and name != "exercise.json"
+ }
+
+ published_node = kolibri_models.ContentNode.objects.get(
+ title="Native QTI Exercise"
+ )
+ assessment_item_ids = set(
+ published_node.assessmentmetadata.first().assessment_item_ids
+ )
+
+ self.assertTrue(item_stems)
+ self.assertEqual(item_stems, assessment_item_ids)
+
+ def test_native_qti_unsupported_interaction_publishes_qti_only(self):
+ node = cc.ContentNode.objects.get(title="Native QTI Unsupported Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
diff --git a/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py b/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py
new file mode 100644
index 0000000000..bd2e4002e2
--- /dev/null
+++ b/contentcuration/contentcuration/tests/utils/qti/test_html_to_markdown.py
@@ -0,0 +1,144 @@
+from le_utils.constants import exercises
+
+from contentcuration.utils.assessment.markdown import render_markdown
+from contentcuration.utils.assessment.qti.html_to_markdown import html_to_markdown
+from contentcuration.utils.assessment.qti.ingest import (
+ strip_content_storage_placeholder,
+)
+from contentcuration.utils.assessment.qti.validation import parse_qti_xml
+
+
+def _elements(fragment):
+ doc = parse_qti_xml("
{}
".format(fragment).encode("utf-8"))
+ return list(doc.getroot())
+
+
+def _markdown_from_html(html):
+ return html_to_markdown(_elements(html))
+
+
+def test_plain_paragraph():
+ assert html_to_markdown(_elements("Hello world
")) == "Hello world"
+
+
+def test_two_paragraphs_join_with_blank_line():
+ result = html_to_markdown(_elements("First
Second
"))
+ assert result == "First\n\nSecond"
+
+
+def test_mathml_annotation_becomes_double_dollar_latex():
+ fragment = (
+ ""
+ )
+ assert "$$x^2$$" in html_to_markdown(_elements(fragment))
+
+
+def test_image_gets_content_storage_prefix():
+ result = html_to_markdown(_elements('
'))
+ expected = "".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png"))
+ assert expected in result
+
+
+def test_interaction_is_dropped_from_prompt():
+ fragment = "Fill in
"
+ assert html_to_markdown(_elements(fragment)) == "Fill in"
+
+
+def test_empty_input_returns_empty_string():
+ assert html_to_markdown(_elements("")) == ""
+ assert html_to_markdown(_elements("
")) == ""
+
+
+def test_headings():
+ assert _markdown_from_html("One
") == "# One"
+ assert _markdown_from_html("Three
") == "### Three"
+
+
+def test_inline_emphasis_styles():
+ assert _markdown_from_html("b
") == "**b**"
+ assert _markdown_from_html("i
") == "*i*"
+ assert _markdown_from_html("gone
") == "~~gone~~"
+ assert _markdown_from_html("x = 1
") == "`x = 1`"
+
+
+def test_link():
+ result = _markdown_from_html('text
')
+ assert result == "[text](https://example.com)"
+
+
+def test_unordered_list_with_nesting():
+ html = ""
+ assert _markdown_from_html(html) == "- one\n - a\n - b\n- two"
+
+
+def test_ordered_list():
+ html = "- first
- second
"
+ assert _markdown_from_html(html) == "1. first\n2. second"
+
+
+def test_blockquote():
+ assert _markdown_from_html("quoted
") == "> quoted"
+
+
+def test_fenced_code_block_with_language():
+ html = 'x = 1\n
'
+ assert _markdown_from_html(html) == "```python\nx = 1\n```"
+
+
+def test_horizontal_rule():
+ assert _markdown_from_html("a
b
") == "a\n\n---\n\nb"
+
+
+def test_table():
+ html = (
+ ""
+ )
+ assert _markdown_from_html(html) == "| A | B |\n| --- | --- |\n| 1 | 2 |"
+
+
+# A single canonical chunk exercising the full range of ``gfm-like`` formatting
+# (plus ``$$…$$`` math and an image) that ``render_markdown`` accepts. The image
+# carries the Perseus content-storage placeholder, exercising the one asymmetric
+# transform: the forward ingest path strips the placeholder before building QTI
+# HTML, and the img rule re-adds it on the way back.
+CANONICAL_MARKDOWN = "\n\n".join(
+ [
+ "# Heading level 1",
+ "## Heading level 2",
+ (
+ "A paragraph with **bold**, *italic*, ~~strikethrough~~, `inline code`, "
+ "a [link](https://example.com), and math $$x^2 + y^2$$ inline."
+ ),
+ "".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png")),
+ "> A blockquote paragraph.",
+ "- First bullet\n- Second bullet\n - Nested bullet\n- Third bullet",
+ "1. First numbered\n2. Second numbered",
+ "```python\nx = 1\ny = 2\n```",
+ "| Column A | Column B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |",
+ "First line \nsecond line after a hard break.",
+ "---",
+ "$$a^2 + b^2 = c^2$$",
+ ]
+)
+
+
+def test_block_math_separates_from_following_block():
+ """Display math is a top-level ``