350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""Перевод HTML Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10)."""
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from sdlc_hub.modules.wiki import convert
|
||
|
||
FIX = Path(__file__).parent / "fixtures" / "wiki"
|
||
FILE_URL = "https://eva.example.test/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1"
|
||
IMG_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/image2026-7-27_14-20-6.png?version=1"
|
||
THUMB_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx.meta/thumbnail.jpg?version=1"
|
||
FILE_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1"
|
||
|
||
|
||
def _html(name):
|
||
return (FIX / name).read_text(encoding="utf-8")
|
||
|
||
|
||
def test_image_becomes_attachment():
|
||
md, atts = convert.to_markdown(
|
||
_html("page_image.html"),
|
||
[{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}],
|
||
[],
|
||
)
|
||
assert "[📎 image2026-7-27_14-20-6.png](attachment:@0)" in md.splitlines()
|
||
assert atts == [{"url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}]
|
||
|
||
|
||
def test_thumbnail_and_file_one_link():
|
||
md, atts = convert.to_markdown(
|
||
_html("page_file.html"),
|
||
[
|
||
{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||
{"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||
],
|
||
[],
|
||
)
|
||
assert [a["name"] for a in atts] == ["model.xlsx"] and len(atts) == 1
|
||
assert md.count("](attachment:@0)") == 1
|
||
assert "[📎 model.xlsx](attachment:@0)" in md
|
||
assert "attach-file" not in md
|
||
|
||
|
||
def test_page_link():
|
||
md, _ = convert.to_markdown(
|
||
_html("page_links.html"),
|
||
[],
|
||
[{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}],
|
||
)
|
||
assert "[Модель данных](page:DOC-019834)" in md
|
||
|
||
|
||
def test_external_link_kept():
|
||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||
assert "(https://example.org)" in md
|
||
|
||
|
||
def test_no_raw_html():
|
||
for name in ("page_image.html", "page_file.html", "page_links.html"):
|
||
md, _ = convert.to_markdown(
|
||
_html(name),
|
||
[{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image.png"},
|
||
{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||
{"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}],
|
||
[{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}],
|
||
)
|
||
assert re.search(r"<[a-zA-Z/]", md) is None, name
|
||
|
||
|
||
def test_table_kept():
|
||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||
table_lines = [line for line in md.splitlines() if "|" in line]
|
||
assert table_lines and any("---" in line for line in table_lines)
|
||
|
||
|
||
def test_headings_atx():
|
||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||
assert "# Требования" in md.splitlines()
|
||
|
||
|
||
def test_static_links_removed():
|
||
html = '<p>Текст <img src="/static/x.png" alt=""> и <a href="static/y.html">ссылка</a></p>'
|
||
md, atts = convert.to_markdown(html, [], [])
|
||
assert "static" not in md and atts == []
|
||
|
||
|
||
def test_fill_ids():
|
||
assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
|
||
|
||
|
||
# Колонка управления строками таблицы редактора Eva (TinyMCE, `table-row-controls`) попадает в HTML страницы и
|
||
# раньше давала абзацы «№», «1», «2»… перед таблицей (#95)
|
||
CARD_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.dbml?version=1"
|
||
|
||
|
||
def test_table_row_controls_removed():
|
||
md, _ = convert.to_markdown(_html("page_table_controls.html"), [], [])
|
||
lines = [line.strip() for line in md.splitlines() if line.strip()]
|
||
assert not [line for line in lines if re.fullmatch(r"№|\d{1,3}", line)], lines
|
||
assert "| № | Замечание | Решение |" in lines
|
||
assert "| 2-бис | Дополнение | Принято |" in lines
|
||
assert "Текст после таблицы." in lines
|
||
|
||
|
||
def test_card_preview_kept_with_controls():
|
||
md, atts = convert.to_markdown(
|
||
_html("page_table_controls.html"),
|
||
[{"ref": CARD_REF, "url": "https://eva.example.test" + CARD_REF, "name": "model.dbml"}],
|
||
[],
|
||
)
|
||
assert "[📎 model.dbml](attachment:@0)" in md and len(atts) == 1
|
||
|
||
|
||
# --- fix round 1 (review Task 6: I1, I2, I3, M3, M4) ---
|
||
|
||
def test_unsafe_schemes_dropped_from_links():
|
||
html = ('<p><a href="javascript:alert(1)">x1</a> <a href="JaVaScript:alert(2)">x2</a> '
|
||
'<a href="data:text/html,<b>x</b>">x3</a> <a href=" java\tscript:alert(3)">x4</a> '
|
||
'<a href="vbscript:msgbox">x5</a></p>')
|
||
md, _ = convert.to_markdown(html, [], [])
|
||
for text in ("x1", "x2", "x3", "x4", "x5"):
|
||
assert text in md
|
||
assert "(javascript" not in md.lower() and "(data:" not in md and "(vbscript" not in md.lower()
|
||
assert "java" not in md.lower().replace("x4", "") # схема не осталась ни в каком виде
|
||
|
||
|
||
def test_unsafe_scheme_img_dropped():
|
||
md, atts = convert.to_markdown('<p>a<img src="javascript:x" alt="z">b</p>', [], [])
|
||
assert md.strip() == "ab" and atts == []
|
||
|
||
|
||
def test_attachment_name_and_link_text_escaped():
|
||
md, atts = convert.to_markdown(
|
||
'<p><a href="/files/a">x</a></p>',
|
||
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "a]b[c).png"}],
|
||
[],
|
||
)
|
||
assert "[📎 a\\]b\\[c).png](attachment:@0)" in md.splitlines()
|
||
assert atts[0]["name"] == "a]b[c).png"
|
||
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="project/Document/DOC-1">a ] b [x](http://evil)</a></p>',
|
||
[],
|
||
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||
)
|
||
assert "[a \\] b \\[x\\](http://evil)](page:DOC-1)" in md.splitlines()
|
||
|
||
|
||
def test_newlines_in_link_text_collapsed():
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="project/Document/DOC-1">первая\nвторая</a></p>', [],
|
||
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||
)
|
||
assert "[первая вторая](page:DOC-1)" in md.splitlines()
|
||
|
||
|
||
def test_literal_attachment_marker_in_text_is_safe():
|
||
md, atts = convert.to_markdown("<p>[x](attachment:@5) и attachment:@0</p>", [], [])
|
||
assert atts == []
|
||
assert convert.fill_ids(md, []) == md
|
||
assert "(attachment:@5)" not in md.replace("\\(attachment:\\@5)", "")
|
||
|
||
|
||
def test_fill_ids_by_index_not_prefix():
|
||
ids = list(range(100, 111))
|
||
assert convert.fill_ids("[a](attachment:@1) [b](attachment:@10)", ids) == \
|
||
"[a](attachment:101) [b](attachment:110)"
|
||
|
||
|
||
def test_fill_ids_out_of_range_raises():
|
||
try:
|
||
convert.fill_ids("[a](attachment:@3)", [1, 2])
|
||
except ValueError as exc:
|
||
assert "@3" in str(exc)
|
||
else:
|
||
raise AssertionError("ожидалась ValueError")
|
||
|
||
|
||
def test_none_lists_are_empty():
|
||
md, atts = convert.to_markdown("<p>текст</p>", None, None)
|
||
assert md == "текст\n" and atts == []
|
||
|
||
|
||
def test_literal_tag_text_has_no_raw_html():
|
||
md, _ = convert.to_markdown("<p><tag> и <script>alert(1)</script></p>", [], [])
|
||
assert re.search(r"<[a-zA-Z/]", md) is None
|
||
assert "<tag>" in md or "<tag" in md
|
||
|
||
|
||
# --- fix round 2 (re-review 1: B1, B2, B3) ---
|
||
|
||
def _no_forged_marker(md, ids=(7,)):
|
||
"""Авторский текст не порождает ссылку вложения: fill_ids ничего не меняет и шаблона нет."""
|
||
assert "](attachment:@" not in md
|
||
assert convert.fill_ids(md, list(ids)) == md
|
||
|
||
|
||
def test_page_link_text_escaped_once():
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="project/Document/DOC-1">a_b *x* c</a></p>', [],
|
||
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||
)
|
||
assert "[a\\_b \\*x\\* c](page:DOC-1)" in md.splitlines()
|
||
assert "\\\\" not in md
|
||
|
||
|
||
def test_page_link_at_sign_not_double_escaped():
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="project/Document/DOC-1">user@example.com</a></p>', [],
|
||
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||
)
|
||
assert "[user@example.com](page:DOC-1)" in md.splitlines()
|
||
|
||
|
||
def test_attachment_name_star_and_underscore_at_word_edge_escaped():
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="/files/a">x</a></p>',
|
||
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "_x_*.png"}],
|
||
[],
|
||
)
|
||
assert "[📎 \\_x\\_\\*.png](attachment:@0)" in md.splitlines()
|
||
|
||
|
||
def test_attachment_name_intraword_underscore_kept():
|
||
md, _ = convert.to_markdown(
|
||
'<p><a href="/files/a">x</a></p>',
|
||
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "my_file.png"}],
|
||
[],
|
||
)
|
||
assert "[📎 my_file.png](attachment:@0)" in md.splitlines()
|
||
|
||
|
||
def test_author_href_attachment_scheme_is_text():
|
||
md, atts = convert.to_markdown('<p><a href="attachment:@5">x</a></p>', [], [])
|
||
assert md.strip() == "x" and atts == []
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_img_src_attachment_scheme_dropped():
|
||
md, atts = convert.to_markdown('<p>a<img src="attachment:@5" alt="z">b</p>', [], [])
|
||
assert md.strip() == "ab" and atts == []
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_alt_cannot_forge_marker():
|
||
md, _ = convert.to_markdown('<p><img src="/x.png" alt="a](attachment:@0)"></p>', [], [])
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_title_cannot_forge_marker():
|
||
md, _ = convert.to_markdown('<p><a href="https://e.org" title="](attachment:@0)">t</a></p>', [], [])
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_code_cannot_forge_marker():
|
||
md, _ = convert.to_markdown("<p><code>](attachment:@0)</code></p>", [], [])
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_text_cannot_forge_marker():
|
||
md, _ = convert.to_markdown("<p>](attachment:@0) и ](attachment:@0)</p>", [], [])
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_author_private_chars_cannot_forge_marker():
|
||
md, _ = convert.to_markdown("<p>0</p>", [], [])
|
||
_no_forged_marker(md)
|
||
|
||
|
||
def test_plain_at_sign_kept():
|
||
md, _ = convert.to_markdown("<p>user@example.com и @ivan</p>", [], [])
|
||
assert "user@example.com и @ivan" in md
|
||
|
||
|
||
def test_code_keeps_literal_text():
|
||
md, _ = convert.to_markdown("<p><code><script> a@b</code></p>", [], [])
|
||
assert "`<script> a@b`" in md
|
||
|
||
|
||
# пакет D (#135): T6.1–T6.5
|
||
IMG_URL = "https://eva.example.test" + IMG_REF
|
||
FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}
|
||
THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}
|
||
IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}
|
||
LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"}
|
||
|
||
|
||
def test_thumbnail_in_wrapper_keeps_link():
|
||
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [THUMB, FILE], [])
|
||
assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1
|
||
|
||
|
||
def test_other_attachment_inside_attachment_link():
|
||
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [FILE, IMG], [])
|
||
assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n"
|
||
assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"]
|
||
|
||
|
||
def test_attachment_inside_page_link():
|
||
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [IMG], [LINK])
|
||
assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n"
|
||
|
||
|
||
def test_attachment_and_text_inside_page_link():
|
||
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [IMG], [LINK])
|
||
assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n"
|
||
|
||
|
||
def test_attachment_inside_external_link():
|
||
md, _ = convert.to_markdown(f'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [IMG], [])
|
||
assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n"
|
||
|
||
|
||
def test_duplicate_ref_first_wins():
|
||
md, atts = convert.to_markdown(f'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
|
||
assert atts == [{"url": IMG_URL, "name": "first"}]
|
||
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK, dict(LINK, code="DOC-9")])
|
||
assert "(page:DOC-1)" in md
|
||
|
||
|
||
def test_external_href_spaces_and_parens_encoded():
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
|
||
assert md == "[док](http://ex.com/a%20b%28c%29.html)\n"
|
||
|
||
|
||
def test_external_href_already_encoded_untouched():
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
|
||
assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md
|
||
|
||
|
||
def test_trailing_backslash_in_page_link_text():
|
||
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [LINK])
|
||
assert md == "[путь C:\\\\](page:DOC-1)\n"
|
||
|
||
|
||
def test_trailing_backslash_in_external_link_text():
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
|
||
assert md == "[путь C:\\\\](http://ex.com/)\n"
|
||
|
||
|
||
def test_external_link_keeps_edge_spaces():
|
||
"""Финальное ревью I-1: пробелы на краях текста внешней ссылки выносятся наружу, слова не склеиваются."""
|
||
md, _ = convert.to_markdown('<p>слово<a href="http://ex.com/"> док </a>дальше</p>', [], [])
|
||
assert md == "слово [док](http://ex.com/) дальше\n"
|
||
|
||
|
||
def test_empty_external_link_dropped():
|
||
"""Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом."""
|
||
md, _ = convert.to_markdown('<p>a<a href="#top"></a>b<a href="http://x"><img src="static/i.png"></a>c</p>', [], [])
|
||
assert md == "abc\n"
|