Skip to content

Commit e9066e4

Browse files
authored
Merge pull request learningequality#6035 from rtibblesbot/issue-6001-e8b010
feat: derive Perseus archives from Perseus-expressible QTI items
2 parents dd5f5d7 + 6eb2139 commit e9066e4

8 files changed

Lines changed: 1121 additions & 27 deletions

File tree

contentcuration/contentcuration/tests/test_exportchannel.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import string
55
import tempfile
66
import uuid
7+
import zipfile
78
from unittest import mock
89

910
import pytest
@@ -35,6 +36,7 @@
3536
from .testdata import slideshow
3637
from .testdata import thumbnail_bytes
3738
from .testdata import tree
39+
from .utils.qti.test_validation import _item_xml
3840
from .utils.qti.test_validation import VALID_CHOICE_ITEM
3941
from .utils.restricted_filesystemstorage import RestrictedFileSystemStorage
4042
from contentcuration import models as cc
@@ -56,6 +58,24 @@
5658

5759
pytestmark = pytest.mark.django_db
5860

61+
# A schema-valid native QTI item whose single interaction (order) Perseus cannot
62+
# express, so a node containing it must publish QTI only.
63+
UNSUPPORTED_QTI_ITEM = _item_xml(
64+
"item_unsupported",
65+
"Unsupported Item",
66+
'<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">'
67+
"<qti-correct-response>"
68+
"<qti-value>choice_0</qti-value>"
69+
"<qti-value>choice_1</qti-value>"
70+
"</qti-correct-response>"
71+
"</qti-response-declaration>",
72+
'<qti-order-interaction response-identifier="RESPONSE">'
73+
"<qti-prompt>Put these in order.</qti-prompt>"
74+
'<qti-simple-choice identifier="choice_0" fixed="false">First</qti-simple-choice>'
75+
'<qti-simple-choice identifier="choice_1" fixed="false">Second</qti-simple-choice>'
76+
"</qti-order-interaction>",
77+
)
78+
5979

6080
def description():
6181
return "".join(random.sample(string.printable, 20))
@@ -282,6 +302,29 @@ def setUp(self):
282302
randomize=False,
283303
)
284304

305+
# Native QTI item whose interaction Perseus cannot express -> QTI only
306+
native_qti_unsupported_exercise = create_node(
307+
{
308+
"kind_id": "exercise",
309+
"title": "Native QTI Unsupported Exercise",
310+
"extra_fields": qti_extra_fields,
311+
}
312+
)
313+
native_qti_unsupported_exercise.complete = True
314+
native_qti_unsupported_exercise.parent = current_exercise.parent
315+
native_qti_unsupported_exercise.save()
316+
cc.AssessmentItem.objects.create(
317+
contentnode=native_qti_unsupported_exercise,
318+
assessment_id=uuid.uuid4().hex,
319+
type=exercises.QTI,
320+
question="",
321+
answers="[]",
322+
hints="[]",
323+
raw_data=UNSUPPORTED_QTI_ITEM,
324+
order=1,
325+
randomize=False,
326+
)
327+
285328
# Only legacy structured-field items, no perseus_question -> must now route to QTI (was Perseus)
286329
legacy_no_perseus_exercise = create_node(
287330
{
@@ -779,8 +822,36 @@ def test_qti_exercise_generates_qti_archive(self):
779822
"QTI file should be a zip archive",
780823
)
781824

782-
def test_native_qti_item_routes_to_qti_packaging(self):
825+
def test_native_qti_choice_item_publishes_both_archives(self):
826+
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
827+
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
828+
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
829+
830+
def test_native_qti_perseus_ids_match_assessment_metadata(self):
831+
"""The derived Perseus item JSON filenames must equal the ids recorded
832+
in the published node's ``AssessmentMetaData.assessment_item_ids`` (the
833+
QTI manifest ``K``-ids), so older Kolibri resolves the derived items."""
783834
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
835+
exercise_file = node.files.get(preset_id=format_presets.EXERCISE)
836+
with exercise_file.file_on_disk.open("rb") as file_handle:
837+
item_stems = {
838+
name[: -len(".json")]
839+
for name in zipfile.ZipFile(file_handle).namelist()
840+
if name.endswith(".json") and name != "exercise.json"
841+
}
842+
843+
published_node = kolibri_models.ContentNode.objects.get(
844+
title="Native QTI Exercise"
845+
)
846+
assessment_item_ids = set(
847+
published_node.assessmentmetadata.first().assessment_item_ids
848+
)
849+
850+
self.assertTrue(item_stems)
851+
self.assertEqual(item_stems, assessment_item_ids)
852+
853+
def test_native_qti_unsupported_interaction_publishes_qti_only(self):
854+
node = cc.ContentNode.objects.get(title="Native QTI Unsupported Exercise")
784855
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
785856
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
786857

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from le_utils.constants import exercises
2+
3+
from contentcuration.utils.assessment.markdown import render_markdown
4+
from contentcuration.utils.assessment.qti.html_to_markdown import html_to_markdown
5+
from contentcuration.utils.assessment.qti.ingest import (
6+
strip_content_storage_placeholder,
7+
)
8+
from contentcuration.utils.assessment.qti.validation import parse_qti_xml
9+
10+
11+
def _elements(fragment):
12+
doc = parse_qti_xml("<div>{}</div>".format(fragment).encode("utf-8"))
13+
return list(doc.getroot())
14+
15+
16+
def _markdown_from_html(html):
17+
return html_to_markdown(_elements(html))
18+
19+
20+
def test_plain_paragraph():
21+
assert html_to_markdown(_elements("<p>Hello world</p>")) == "Hello world"
22+
23+
24+
def test_two_paragraphs_join_with_blank_line():
25+
result = html_to_markdown(_elements("<p>First</p><p>Second</p>"))
26+
assert result == "First\n\nSecond"
27+
28+
29+
def test_mathml_annotation_becomes_double_dollar_latex():
30+
fragment = (
31+
"<p><math><semantics><mrow><mi>x</mi></mrow>"
32+
'<annotation encoding="application/x-tex">x^2</annotation>'
33+
"</semantics></math></p>"
34+
)
35+
assert "$$x^2$$" in html_to_markdown(_elements(fragment))
36+
37+
38+
def test_image_gets_content_storage_prefix():
39+
result = html_to_markdown(_elements('<p><img alt="d" src="abc123.png"/></p>'))
40+
expected = "![d]({})".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png"))
41+
assert expected in result
42+
43+
44+
def test_interaction_is_dropped_from_prompt():
45+
fragment = "<p>Fill <qti-text-entry-interaction/> in</p>"
46+
assert html_to_markdown(_elements(fragment)) == "Fill in"
47+
48+
49+
def test_empty_input_returns_empty_string():
50+
assert html_to_markdown(_elements("")) == ""
51+
assert html_to_markdown(_elements("<p> </p>")) == ""
52+
53+
54+
def test_headings():
55+
assert _markdown_from_html("<h1>One</h1>") == "# One"
56+
assert _markdown_from_html("<h3>Three</h3>") == "### Three"
57+
58+
59+
def test_inline_emphasis_styles():
60+
assert _markdown_from_html("<p><strong>b</strong></p>") == "**b**"
61+
assert _markdown_from_html("<p><em>i</em></p>") == "*i*"
62+
assert _markdown_from_html("<p><s>gone</s></p>") == "~~gone~~"
63+
assert _markdown_from_html("<p><code>x = 1</code></p>") == "`x = 1`"
64+
65+
66+
def test_link():
67+
result = _markdown_from_html('<p><a href="https://example.com">text</a></p>')
68+
assert result == "[text](https://example.com)"
69+
70+
71+
def test_unordered_list_with_nesting():
72+
html = "<ul><li>one<ul><li>a</li><li>b</li></ul></li><li>two</li></ul>"
73+
assert _markdown_from_html(html) == "- one\n - a\n - b\n- two"
74+
75+
76+
def test_ordered_list():
77+
html = "<ol><li>first</li><li>second</li></ol>"
78+
assert _markdown_from_html(html) == "1. first\n2. second"
79+
80+
81+
def test_blockquote():
82+
assert _markdown_from_html("<blockquote><p>quoted</p></blockquote>") == "> quoted"
83+
84+
85+
def test_fenced_code_block_with_language():
86+
html = '<pre><code class="language-python">x = 1\n</code></pre>'
87+
assert _markdown_from_html(html) == "```python\nx = 1\n```"
88+
89+
90+
def test_horizontal_rule():
91+
assert _markdown_from_html("<p>a</p><hr/><p>b</p>") == "a\n\n---\n\nb"
92+
93+
94+
def test_table():
95+
html = (
96+
"<table><thead><tr><th>A</th><th>B</th></tr></thead>"
97+
"<tbody><tr><td>1</td><td>2</td></tr></tbody></table>"
98+
)
99+
assert _markdown_from_html(html) == "| A | B |\n| --- | --- |\n| 1 | 2 |"
100+
101+
102+
# A single canonical chunk exercising the full range of ``gfm-like`` formatting
103+
# (plus ``$$…$$`` math and an image) that ``render_markdown`` accepts. The image
104+
# carries the Perseus content-storage placeholder, exercising the one asymmetric
105+
# transform: the forward ingest path strips the placeholder before building QTI
106+
# HTML, and the img rule re-adds it on the way back.
107+
CANONICAL_MARKDOWN = "\n\n".join(
108+
[
109+
"# Heading level 1",
110+
"## Heading level 2",
111+
(
112+
"A paragraph with **bold**, *italic*, ~~strikethrough~~, `inline code`, "
113+
"a [link](https://example.com), and math $$x^2 + y^2$$ inline."
114+
),
115+
"![alt text]({})".format(exercises.CONTENT_STORAGE_FORMAT.format("abc123.png")),
116+
"> A blockquote paragraph.",
117+
"- First bullet\n- Second bullet\n - Nested bullet\n- Third bullet",
118+
"1. First numbered\n2. Second numbered",
119+
"```python\nx = 1\ny = 2\n```",
120+
"| Column A | Column B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |",
121+
"First line \nsecond line after a hard break.",
122+
"---",
123+
"$$a^2 + b^2 = c^2$$",
124+
]
125+
)
126+
127+
128+
def test_block_math_separates_from_following_block():
129+
"""Display math is a top-level ``<math display="block">`` sibling in the
130+
forward HTML; it must keep the blank line before the next block rather than
131+
gluing the following paragraph onto the math line."""
132+
md = "Given the equation:\n\n$$E = mc^2$$\n\nExplain what it means."
133+
assert _markdown_from_html(render_markdown(md)) == md
134+
135+
136+
def test_round_trips_losslessly_with_render_markdown():
137+
"""markdown -> HTML -> markdown is identity through the real forward path.
138+
139+
The forward transform mirrors the ingest pipeline: strip the content-storage
140+
placeholder (``ingest.py``) before ``render_markdown`` builds the QTI HTML,
141+
which is why images round-trip losslessly in production.
142+
"""
143+
html = render_markdown(strip_content_storage_placeholder(CANONICAL_MARKDOWN))
144+
assert _markdown_from_html(html) == CANONICAL_MARKDOWN

0 commit comments

Comments
 (0)