sdlc-hub/tests/test_wiki_convert.py
Andrey Epifancev 7b18ae53ad Вики: таблицы версий и приём POST /agent/wiki/versions (#140)
- schema: wiki_version, wiki_version_stage, wiki_history (без миграций, IF NOT EXISTS)
- history.utc: время версии в UTC; без смещения или не строка — None
- convert.to_markdown(file_links=False): вложение текстом «📎 имя», список файлов пуст
- stage.accept_versions + POST /agent/wiki/versions (agent wiki): проверка формы, перевод вне транзакции
- эталоны baseline (routes, schema) и документы: design.md (П-1…П-12), ARCHITECTURE.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-28 11:57:37 +04:00

360 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Перевод 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>&lt;tag&gt; и &lt;script&gt;alert(1)&lt;/script&gt;</p>", [], [])
assert re.search(r"<[a-zA-Z/]", md) is None
assert "&lt;tag&gt;" in md or "&lt;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) и &#93;(attachment:&#64;0)</p>", [], [])
_no_forged_marker(md)
def test_author_private_chars_cannot_forge_marker():
md, _ = convert.to_markdown("<p>&#57344;0&#57345;</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>&lt;script&gt; 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"
def test_version_markdown_attachment_as_text():
md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False)
assert "📎 model.xlsx" in md and "attachment:" not in md and files == []
def test_version_markdown_page_links_kept():
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
assert md == "[т](page:DOC-1)\n"