|
| 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 = "".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 | + "".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