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
73 changes: 72 additions & 1 deletion contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import string
import tempfile
import uuid
import zipfile
from unittest import mock

import pytest
Expand Down Expand Up @@ -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
Expand All @@ -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",
'<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">'
"<qti-correct-response>"
"<qti-value>choice_0</qti-value>"
"<qti-value>choice_1</qti-value>"
"</qti-correct-response>"
"</qti-response-declaration>",
'<qti-order-interaction response-identifier="RESPONSE">'
"<qti-prompt>Put these in order.</qti-prompt>"
'<qti-simple-choice identifier="choice_0" fixed="false">First</qti-simple-choice>'
'<qti-simple-choice identifier="choice_1" fixed="false">Second</qti-simple-choice>'
"</qti-order-interaction>",
)


def description():
return "".join(random.sample(string.printable, 20))
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
@@ -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("<div>{}</div>".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("<p>Hello world</p>")) == "Hello world"


def test_two_paragraphs_join_with_blank_line():
result = html_to_markdown(_elements("<p>First</p><p>Second</p>"))
assert result == "First\n\nSecond"


def test_mathml_annotation_becomes_double_dollar_latex():
fragment = (
"<p><math><semantics><mrow><mi>x</mi></mrow>"
'<annotation encoding="application/x-tex">x^2</annotation>'
"</semantics></math></p>"
)
assert "$$x^2$$" in html_to_markdown(_elements(fragment))


def test_image_gets_content_storage_prefix():
result = html_to_markdown(_elements('<p><img alt="d" src="abc123.png"/></p>'))
expected = "![d]({})".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png"))
assert expected in result


def test_interaction_is_dropped_from_prompt():
fragment = "<p>Fill <qti-text-entry-interaction/> in</p>"
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("<p> </p>")) == ""


def test_headings():
assert _markdown_from_html("<h1>One</h1>") == "# One"
assert _markdown_from_html("<h3>Three</h3>") == "### Three"


def test_inline_emphasis_styles():
assert _markdown_from_html("<p><strong>b</strong></p>") == "**b**"
assert _markdown_from_html("<p><em>i</em></p>") == "*i*"
assert _markdown_from_html("<p><s>gone</s></p>") == "~~gone~~"
assert _markdown_from_html("<p><code>x = 1</code></p>") == "`x = 1`"


def test_link():
result = _markdown_from_html('<p><a href="https://example.com">text</a></p>')
assert result == "[text](https://example.com)"


def test_unordered_list_with_nesting():
html = "<ul><li>one<ul><li>a</li><li>b</li></ul></li><li>two</li></ul>"
assert _markdown_from_html(html) == "- one\n - a\n - b\n- two"


def test_ordered_list():
html = "<ol><li>first</li><li>second</li></ol>"
assert _markdown_from_html(html) == "1. first\n2. second"


def test_blockquote():
assert _markdown_from_html("<blockquote><p>quoted</p></blockquote>") == "> quoted"


def test_fenced_code_block_with_language():
html = '<pre><code class="language-python">x = 1\n</code></pre>'
assert _markdown_from_html(html) == "```python\nx = 1\n```"


def test_horizontal_rule():
assert _markdown_from_html("<p>a</p><hr/><p>b</p>") == "a\n\n---\n\nb"


def test_table():
html = (
"<table><thead><tr><th>A</th><th>B</th></tr></thead>"
"<tbody><tr><td>1</td><td>2</td></tr></tbody></table>"
)
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."
),
"![alt text]({})".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 ``<math display="block">`` sibling in the
forward HTML; it must keep the blank line before the next block rather than
gluing the following paragraph onto the math line."""
md = "Given the equation:\n\n$$E = mc^2$$\n\nExplain what it means."
assert _markdown_from_html(render_markdown(md)) == md


def test_round_trips_losslessly_with_render_markdown():
Comment thread
rtibbles marked this conversation as resolved.
"""markdown -> HTML -> markdown is identity through the real forward path.

The forward transform mirrors the ingest pipeline: strip the content-storage
placeholder (``ingest.py``) before ``render_markdown`` builds the QTI HTML,
which is why images round-trip losslessly in production.
"""
html = render_markdown(strip_content_storage_placeholder(CANONICAL_MARKDOWN))
assert _markdown_from_html(html) == CANONICAL_MARKDOWN
Loading
Loading