2026-08-17 13:05:19 +03:00
|
|
|
|
// Тест MdParser (Фаза 6): рендер Markdown → HTML
|
|
|
|
|
|
// (заголовки, жирный/курсив/зачёркнутый, таблицы, ссылки, картинки,
|
|
|
|
|
|
// задачи с переключаемыми ссылками, подсветка кода, вики-ссылки).
|
|
|
|
|
|
#include <QCoreApplication>
|
|
|
|
|
|
#include <QTemporaryDir>
|
|
|
|
|
|
#include <QDir>
|
|
|
|
|
|
#include <QFile>
|
2026-09-06 21:17:25 +03:00
|
|
|
|
#include <QEventLoop>
|
|
|
|
|
|
#include <QTimer>
|
2026-08-17 13:05:19 +03:00
|
|
|
|
#include <cstdio>
|
|
|
|
|
|
|
|
|
|
|
|
#include "markdown/MdParser.h"
|
|
|
|
|
|
|
|
|
|
|
|
static int fail(const char *msg, const QString &detail = QString())
|
|
|
|
|
|
{
|
|
|
|
|
|
std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail));
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int main(int argc, char *argv[])
|
|
|
|
|
|
{
|
|
|
|
|
|
QCoreApplication app(argc, argv);
|
|
|
|
|
|
|
|
|
|
|
|
QTemporaryDir dir;
|
|
|
|
|
|
if (!dir.isValid())
|
|
|
|
|
|
return fail("temp dir");
|
|
|
|
|
|
|
|
|
|
|
|
const QString vault = dir.path();
|
|
|
|
|
|
QDir(vault).mkpath("sub");
|
|
|
|
|
|
{
|
|
|
|
|
|
QFile f(vault + "/sub/b.md");
|
|
|
|
|
|
f.open(QIODevice::WriteOnly);
|
|
|
|
|
|
f.write("b");
|
|
|
|
|
|
f.close();
|
|
|
|
|
|
QFile g(vault + "/sub/pic.png");
|
|
|
|
|
|
g.open(QIODevice::WriteOnly);
|
|
|
|
|
|
g.write("\x89PNG fake");
|
|
|
|
|
|
g.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MdParser p;
|
|
|
|
|
|
const QString fileDir = vault + "/sub";
|
|
|
|
|
|
|
|
|
|
|
|
auto probe = [](const QString &html, const char *sub) {
|
|
|
|
|
|
return html.contains(QString::fromUtf8(sub));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Заголовок
|
|
|
|
|
|
QString h = p.render("# Заголовок\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(h, "<h1>Заголовок</h1>"))
|
|
|
|
|
|
return fail("заголовок", h);
|
|
|
|
|
|
|
|
|
|
|
|
// Жирный / курсив / зачёркнутый
|
|
|
|
|
|
QString f = p.render("**ж** *к* ~~з~~\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(f, "<b>ж</b>") || !probe(f, "<i>к</i>") || !probe(f, "<s>з</s>"))
|
|
|
|
|
|
return fail("инлайн-разметка", f);
|
|
|
|
|
|
|
|
|
|
|
|
// Таблица GFM
|
|
|
|
|
|
QString t = p.render("| A | B |\n|---|---|\n| 1 | 2 |\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(t, "<table") || !probe(t, "<th") || !probe(t, "<td"))
|
|
|
|
|
|
return fail("таблица", t);
|
|
|
|
|
|
|
|
|
|
|
|
// Задачи (переключаемые ссылки + галочки)
|
|
|
|
|
|
QString sk = p.render("- [ ] задача\n- [x] готова\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(sk, "flora://task/") || !probe(sk, "\u2610") || !probe(sk, "\u2611"))
|
|
|
|
|
|
return fail("задачи", sk);
|
|
|
|
|
|
|
|
|
|
|
|
// Относительная ссылка на существующий файл → flora://open
|
|
|
|
|
|
QString lk = p.render("[б](b.md)\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(lk, "flora://open/") || !probe(lk, ">б</a>"))
|
|
|
|
|
|
return fail("ссылка", lk);
|
|
|
|
|
|
|
|
|
|
|
|
// Вики-ссылка [[b]]
|
|
|
|
|
|
QString wk = p.render("[[b]]\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(wk, "flora://open/") || !probe(wk, ">b</a>"))
|
|
|
|
|
|
return fail("вики-ссылка", wk);
|
|
|
|
|
|
|
|
|
|
|
|
// Подсветка кода python (должны появиться цветные span'ы)
|
|
|
|
|
|
QString c = p.render("```python\nprint(\"hi\")\n```\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(c, "language-Python") || !probe(c, "style=\"color:"))
|
|
|
|
|
|
return fail("подсветка кода", c);
|
|
|
|
|
|
|
|
|
|
|
|
// Простой код без языка — не должен падать
|
|
|
|
|
|
p.render("```\nпросто\n```\n", vault, fileDir);
|
|
|
|
|
|
|
|
|
|
|
|
// Язык «С++» из кириллицы должен мапиться на C++ (подсветка сработает)
|
|
|
|
|
|
QString cpp = p.render("```С++\nint x = 1;\n```\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(cpp, "language-C++"))
|
|
|
|
|
|
return fail("алиас С++", cpp);
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Экранирование HTML — опасные теги должны экранироваться
|
2026-08-17 13:05:19 +03:00
|
|
|
|
QString e = p.render("<script>alert(1)</script>\n", vault, fileDir);
|
|
|
|
|
|
if (probe(e, "<script>"))
|
2026-09-01 19:25:07 +03:00
|
|
|
|
return fail("экранирование script", e);
|
|
|
|
|
|
// Безопасные теги должны рендериться как HTML
|
|
|
|
|
|
QString sup = p.render("x<sup>2</sup>=4\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(sup, "<sup>2</sup>"))
|
|
|
|
|
|
return fail("sup не отрендерился", sup);
|
|
|
|
|
|
if (sup.trimmed().isEmpty())
|
|
|
|
|
|
return fail("sup пустой", sup);
|
|
|
|
|
|
QString span = p.render("<span style=\"background-color:#ffeb3b\">hi</span>\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(span, "<span") || !probe(span, "background-color"))
|
|
|
|
|
|
return fail("span не отрендерился", span);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
|
|
|
|
|
|
// Картинка → data: или placeholder (файл невалидный PNG — data не выйдет)
|
|
|
|
|
|
QString im = p.render("\n", vault, fileDir);
|
|
|
|
|
|
if (!probe(im, "<img") && !probe(im, "изображение"))
|
|
|
|
|
|
return fail("картинка", im);
|
|
|
|
|
|
|
2026-09-01 21:24:37 +03:00
|
|
|
|
// CodeBlock отдельный компонент — parseBlocks
|
|
|
|
|
|
auto blocks = p.parseBlocks("# Hello\n\n```cpp\nint x=1;\n```\n\nText", vault, fileDir);
|
|
|
|
|
|
if (blocks.size() != 3) return fail("parseBlocks size", QString::number(blocks.size()));
|
|
|
|
|
|
if (blocks[0].toMap().value("type").toString() != "html") return fail("block 0 type", blocks[0].toMap().value("type").toString());
|
|
|
|
|
|
if (blocks[1].toMap().value("type").toString() != "code") return fail("block 1 type", blocks[1].toMap().value("type").toString());
|
|
|
|
|
|
if (blocks[1].toMap().value("language").toString() != "C++" && blocks[1].toMap().value("language").toString() != "cpp") return fail("block 1 lang", blocks[1].toMap().value("language").toString());
|
2026-09-01 21:47:40 +03:00
|
|
|
|
{
|
|
|
|
|
|
QString codeVal = blocks[1].toMap().value("codeText").toString();
|
|
|
|
|
|
if (codeVal.isEmpty()) codeVal = blocks[1].toMap().value("code").toString();
|
|
|
|
|
|
if (!codeVal.contains("int x=1;")) return fail("block 1 code", codeVal);
|
|
|
|
|
|
}
|
|
|
|
|
|
{
|
|
|
|
|
|
QString hl = blocks[1].toMap().value("highlightedText").toString();
|
|
|
|
|
|
if (hl.isEmpty()) hl = blocks[1].toMap().value("highlighted").toString();
|
|
|
|
|
|
if (hl.isEmpty()) return fail("highlighted empty");
|
2026-09-02 00:01:03 +03:00
|
|
|
|
// Debug: print highlighted
|
|
|
|
|
|
std::fprintf(stderr, "highlighted: %s\n", qPrintable(hl.left(500)));
|
2026-09-01 21:47:40 +03:00
|
|
|
|
}
|
2026-09-01 21:24:37 +03:00
|
|
|
|
if (blocks[2].toMap().value("type").toString() != "html") return fail("block 2 type");
|
2026-09-02 00:01:03 +03:00
|
|
|
|
// Test with #include
|
|
|
|
|
|
{
|
|
|
|
|
|
auto b2 = p.parseBlocks("```cpp\n#include <stdio.h>\nint main() { return 0; }\n```", vault, fileDir);
|
|
|
|
|
|
if (b2.size() != 1 || b2[0].toMap().value("type").toString() != "code") return fail("include block");
|
|
|
|
|
|
QString hl2 = b2[0].toMap().value("highlightedText").toString();
|
|
|
|
|
|
if (hl2.isEmpty()) hl2 = b2[0].toMap().value("highlighted").toString();
|
|
|
|
|
|
std::fprintf(stderr, "hl2: %s\n", qPrintable(hl2.left(800)));
|
|
|
|
|
|
if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200));
|
|
|
|
|
|
}
|
2026-09-01 21:24:37 +03:00
|
|
|
|
|
2026-09-06 19:26:53 +03:00
|
|
|
|
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
|
|
|
|
|
|
{
|
2026-09-06 23:02:05 +03:00
|
|
|
|
auto b = p.parseBlocks("```cpp\nint a = 1;\nint b = 2;\n```\n", vault, fileDir);
|
|
|
|
|
|
if (b.size() != 1) return fail("br блок");
|
|
|
|
|
|
const QString hl = b[0].toMap().value("highlightedText").toString();
|
2026-09-06 19:26:53 +03:00
|
|
|
|
if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
|
|
|
|
|
|
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseBlocksWithDiff: версия old vs текущее new
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString oldMd = "# Заголовок\n\nСтарая строка\n\n```cpp\nint x = 1;\n```\n";
|
|
|
|
|
|
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
|
|
|
|
|
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
|
|
|
|
|
if (d.isEmpty()) return fail("diff пуст");
|
2026-09-06 20:30:01 +03:00
|
|
|
|
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add,
|
|
|
|
|
|
// поэтому пары del/add здесь не соседствуют и merge не срабатывает — отдельные блоки
|
2026-09-06 19:26:53 +03:00
|
|
|
|
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
|
|
|
|
|
|
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
|
|
|
|
|
|
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0));
|
|
|
|
|
|
if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип");
|
|
|
|
|
|
if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1));
|
2026-09-06 20:30:01 +03:00
|
|
|
|
if (!d[1].toMap().value("html").toString().contains("244,63,94")) return fail("diff[1] без розовой подсветки");
|
2026-09-06 19:26:53 +03:00
|
|
|
|
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
|
|
|
|
|
|
return fail("diff[2] код del", changeOf(2));
|
2026-09-06 20:18:05 +03:00
|
|
|
|
if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94"))
|
|
|
|
|
|
return fail("diff[2] код без розовой подсветки");
|
2026-09-06 19:26:53 +03:00
|
|
|
|
if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3));
|
2026-09-06 20:30:01 +03:00
|
|
|
|
if (!d[3].toMap().value("html").toString().contains("34,197,94")) return fail("diff[3] без зелёной подсветки");
|
2026-09-06 19:26:53 +03:00
|
|
|
|
if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
|
|
|
|
|
|
return fail("diff[4] код add", changeOf(4));
|
|
|
|
|
|
// кодовые блоки сохранили id для копирования
|
|
|
|
|
|
if (d[2].toMap().value("codeId").toString().isEmpty()) return fail("diff codeId пуст");
|
|
|
|
|
|
if (p.getCodeBlock(d[2].toMap().value("codeId").toString()) != "int x = 1;")
|
|
|
|
|
|
return fail("getCodeBlock после diff", p.getCodeBlock(d[2].toMap().value("codeId").toString()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseBlocksWithDiff: идентичные тексты — всё same, без подсветки
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString md = "# A\n\ntext\n";
|
|
|
|
|
|
auto d = p.parseBlocksWithDiff(md, md, vault, fileDir);
|
|
|
|
|
|
for (const auto &v : d) {
|
|
|
|
|
|
if (v.toMap().value("change").toString() != "same")
|
|
|
|
|
|
return fail("identical должен быть same");
|
2026-09-06 20:30:01 +03:00
|
|
|
|
if (v.toMap().value("html").toString().contains("244,63,94")
|
|
|
|
|
|
|| v.toMap().value("html").toString().contains("34,197,94"))
|
2026-09-06 19:26:53 +03:00
|
|
|
|
return fail("identical с подсветкой");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 22:17:13 +03:00
|
|
|
|
// ~~~-заборы тоже разбираются на code-блоки (единая разбивка splitMdBlocks)
|
|
|
|
|
|
{
|
|
|
|
|
|
auto b = p.parseBlocks("~~~\ncode\n~~~\n", vault, fileDir);
|
|
|
|
|
|
if (b.size() != 1 || b[0].toMap().value("type").toString() != "code")
|
|
|
|
|
|
return fail("tilde fence");
|
|
|
|
|
|
if (!b[0].toMap().value("codeText").toString().contains("code"))
|
|
|
|
|
|
return fail("tilde code");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 20:49:14 +03:00
|
|
|
|
// parseBlocksWithDiff: правка в обычном тексте — два отдельных tinted-блока
|
|
|
|
|
|
// (del, затем add), как стандартный diff; словного уровня нет
|
2026-09-06 20:30:01 +03:00
|
|
|
|
{
|
|
|
|
|
|
const QString oldMd = "# T\n\nOld words here\n";
|
|
|
|
|
|
const QString newMd = "# T\n\nNew words here\n";
|
|
|
|
|
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
2026-09-06 20:49:14 +03:00
|
|
|
|
if (d.size() != 3) return fail("text diff размер", QString::number(d.size()));
|
|
|
|
|
|
if (d[0].toMap().value("change").toString() != "same") return fail("text diff[0] same");
|
2026-09-06 20:30:01 +03:00
|
|
|
|
if (d[1].toMap().value("change").toString() != "del"
|
|
|
|
|
|
|| d[2].toMap().value("change").toString() != "add")
|
2026-09-06 20:49:14 +03:00
|
|
|
|
return fail("text diff del/add");
|
|
|
|
|
|
if (!d[1].toMap().value("html").toString().contains("244,63,94"))
|
|
|
|
|
|
return fail("text del без розового");
|
|
|
|
|
|
if (!d[2].toMap().value("html").toString().contains("34,197,94"))
|
|
|
|
|
|
return fail("text add без зелёного");
|
2026-09-06 20:30:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 21:17:25 +03:00
|
|
|
|
// parseBlocksWithDiffAsync: тот же результат, что sync, id совпадает
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString oldMd = "# T\n\n```cpp\nint a = 1;\n```\n";
|
|
|
|
|
|
const QString newMd = "# T\n\n```cpp\nint a = 2;\n```\n";
|
|
|
|
|
|
QVariantList expect = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
|
|
|
|
|
const int reqId = p.parseBlocksWithDiffAsync(oldMd, newMd, vault, fileDir);
|
|
|
|
|
|
QVariantList got;
|
|
|
|
|
|
int gotId = -1;
|
|
|
|
|
|
QObject::connect(&p, &MdParser::blocksReady,
|
|
|
|
|
|
[&](int id, const QVariantList &b) { gotId = id; got = b; });
|
|
|
|
|
|
QEventLoop loop;
|
|
|
|
|
|
QTimer timer;
|
|
|
|
|
|
timer.setSingleShot(true);
|
|
|
|
|
|
QObject::connect(&p, &MdParser::blocksReady, &loop, &QEventLoop::quit);
|
|
|
|
|
|
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
|
|
|
|
|
timer.start(10000);
|
|
|
|
|
|
loop.exec();
|
|
|
|
|
|
if (gotId != reqId) return fail("async id", QString::number(gotId));
|
|
|
|
|
|
if (got.size() != expect.size()) return fail("async размер", QString::number(got.size()));
|
|
|
|
|
|
for (int i = 0; i < got.size(); ++i) {
|
|
|
|
|
|
const QVariantMap g = got[i].toMap(), e = expect[i].toMap();
|
|
|
|
|
|
if (g.value("type") != e.value("type") || g.value("change") != e.value("change"))
|
|
|
|
|
|
return fail("async блок", QString::number(i));
|
|
|
|
|
|
if (g.value("type").toString() == "code") {
|
|
|
|
|
|
// копирование работает и после async (id свежие, код — новый)
|
|
|
|
|
|
if (!p.getCodeBlock(g.value("codeId").toString()).contains("int a = 2;"))
|
|
|
|
|
|
return fail("async copy", p.getCodeBlock(g.value("codeId").toString()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 20:18:05 +03:00
|
|
|
|
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
|
|
|
|
|
const QString newMd = "# T\n\n```cpp\nint a = 1;\nint b = 3;\n```\n";
|
|
|
|
|
|
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
|
|
|
|
|
if (d.size() != 2) return fail("merge размер", QString::number(d.size()));
|
|
|
|
|
|
if (d[0].toMap().value("change").toString() != "same") return fail("merge[0] same");
|
|
|
|
|
|
if (d[1].toMap().value("type").toString() != "code"
|
|
|
|
|
|
|| d[1].toMap().value("change").toString() != "mod")
|
|
|
|
|
|
return fail("merge[1] должен быть code/mod", d[1].toMap().value("change").toString());
|
|
|
|
|
|
const QString hl = d[1].toMap().value("highlightedText").toString();
|
|
|
|
|
|
if (!hl.contains("244,63,94")) return fail("merge без розовой строки");
|
|
|
|
|
|
if (!hl.contains("34,197,94")) return fail("merge без зелёной строки");
|
|
|
|
|
|
// маркеры −/+ у изменённых строк (текст режется спанами подсветки, ищем маркеры)
|
|
|
|
|
|
if (!hl.contains(QChar(0x2212))) return fail("merge без маркера −");
|
|
|
|
|
|
if (!hl.contains("+ </span>")) return fail("merge без маркера +");
|
|
|
|
|
|
// копируется новое содержимое
|
|
|
|
|
|
const QString cid = d[1].toMap().value("codeId").toString();
|
|
|
|
|
|
if (cid.isEmpty()) return fail("merge codeId пуст");
|
|
|
|
|
|
if (!p.getCodeBlock(cid).contains("int b = 3;")) return fail("merge copy даёт не новое", p.getCodeBlock(cid));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 13:05:19 +03:00
|
|
|
|
std::printf("MDPARSER TEST OK\n");
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|