From 347c744f27cabc6f4c0ae9c4284a2b6ecd096833 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 2 Nov 2026 22:31:49 +0100 Subject: [PATCH] Test message markup, syntax state and formatted text layout --- tools/chatgui/CMakeLists.txt | 5 ++ tools/chatgui/tests/test_message_text.cpp | 105 ++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tools/chatgui/tests/test_message_text.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 0cf599238..32a956caf 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -785,6 +785,11 @@ foreach(message_target vibechat test_file_drop test_emoji_playback test_invite_l target_link_libraries(${message_target} PRIVATE chatgui_message_text) endforeach() +add_executable(test_message_text tests/test_message_text.cpp) +target_link_libraries(test_message_text PRIVATE chatgui_message_text ${QT_TEST_LIB} utun) +add_test(NAME test_message_text COMMAND test_message_text) +set_tests_properties(test_message_text PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) + add_executable(test_join_dialogs tests/test_join_dialogs.cpp src/joindialog.cpp src/inviteby.cpp src/invite_link.cpp "${CMAKE_CURRENT_BINARY_DIR}/translations.qrc") add_dependencies(test_join_dialogs chatgui_translations) diff --git a/tools/chatgui/tests/test_message_text.cpp b/tools/chatgui/tests/test_message_text.cpp new file mode 100644 index 000000000..8f897f802 --- /dev/null +++ b/tools/chatgui/tests/test_message_text.cpp @@ -0,0 +1,105 @@ +/* Разметка, реальные языковые определения, кеш и метрики текста без участия сетевого ядра. */ +#include "../src/message_text.h" +#include "../src/messagedelegate.h" +#include "../../../lib/debug_config.h" +#include +#include + +class MessageTextTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); } + + void parse_data() { + QTest::addColumn("source"); QTest::addColumn("visible"); + QTest::addColumn("styles"); QTest::addColumn("blocks"); + QTest::newRow("plain") << "hello\nworld" << "hello\nworld" << 0 << 0; + QTest::newRow("styles") << "**жирный** *наклонный* ~~удалённый~~" << "жирный наклонный удалённый" << 3 << 0; + QTest::newRow("unclosed") << "**hello *world ~~bye" << "**hello *world ~~bye" << 0 << 0; + QTest::newRow("no-multiline-style") << "**hello\nworld**" << "**hello\nworld**" << 0 << 0; + QTest::newRow("empty-style") << "**** ** ** ~~~~" << "**** ** ** ~~~~" << 0 << 0; + QTest::newRow("no-nesting") << "**hello *world* **" << "**hello world **" << 1 << 0; + QTest::newRow("nested-literal") << "**hello *world***" << "**hello *world***" << 0 << 0; + QTest::newRow("escapes") << R"(\*hello\* \\ ~~bye~~)" << "*hello* \\ bye" << 1 << 0; + QTest::newRow("code") << "before\n```cpp\n int x;\n\t/* **literal** */\n\n```\nafter" + << "before\n int x;\n\t/* **literal** */\n\nafter" << 0 << 1; + QTest::newRow("empty-code") << "```\n```" << "" << 0 << 1; + QTest::newRow("blank-code") << "```\n\n\n```" << "\n" << 0 << 1; + QTest::newRow("two-blocks") << "```c\nint x;\n```\n**bold**\n```python\npass\n```" + << "int x;\nbold\npass" << 1 << 2; + QTest::newRow("unclosed-code") << "```cpp\nint x;" << "```cpp\nint x;" << 0 << 0; + QTest::newRow("inline-fence") << "text ``` code ```" << "text ``` code ```" << 0 << 0; + QTest::newRow("indented-fence") << " ```\nx\n ```" << " ```\nx\n ```" << 0 << 0; + QTest::newRow("trailing-fence-text") << "```cpp extra\nx\n```" << "```cpp extra\nx\n```" << 0 << 0; + QTest::newRow("no-word-markup") << "some*identifier* a**b**c" << "some*identifier* a**b**c" << 0 << 0; + QTest::newRow("url-markers") << "https://example.com/*path*/ **https://example.org**" + << "https://example.com/*path*/ https://example.org" << 1 << 0; + } + void parse() { + QFETCH(QString, source); QFETCH(QString, visible); QFETCH(int, styles); QFETCH(int, blocks); + const auto parsed = parseMessageText(source); + QCOMPARE(parsed.text, visible); QCOMPARE(parsed.styles.size(), styles); QCOMPARE(parsed.code.size(), blocks); + for (const auto& style : parsed.styles) { + QVERIFY(style.start >= 0); QVERIFY(style.length > 0); QVERIFY(style.start + style.length <= parsed.text.size()); + } + } + + void stylePropertiesAndCodeLinks() { + const auto parsed = parseMessageText("**bold** *italic* ~~strike~~\n```cpp\nhttps://example.com\n```\nhttps://example.org"); + QCOMPARE(parsed.styles[0].format.fontWeight(), QFont::Bold); + QVERIFY(parsed.styles[1].format.fontItalic()); QVERIFY(parsed.styles[2].format.fontStrikeOut()); + QCOMPARE(parsed.links.size(), 1); QCOMPARE(parsed.links[0].url, QString("https://example.org")); + QVERIFY(parsed.isCode(parsed.text.indexOf("https://example.com"))); + QVERIFY(!parsed.isCode(parsed.links[0].start)); + } + + void syntaxAndCache() { + QStandardItemModel model(1, 1); const auto index = model.index(0, 0); + const QString source = "```cpp\n/* comment\n continued */\nint x = 42;\n```"; + model.setData(index, source, MsgTextRole); + const auto parsed = formattedMessageText(index); QVERIFY(parsed); + QCOMPARE(parsed->text, QString("/* comment\n continued */\nint x = 42;")); + QVERIFY(!parsed->lightSyntax.isEmpty()); QVERIFY(!parsed->darkSyntax.isEmpty()); + auto colorAt = [&](int position) { + QColor color; + for (const auto& token : parsed->lightSyntax) + if (position >= token.start && position < token.start + token.length) color = token.format.foreground().color(); + return color; + }; + QVERIFY(colorAt(0).isValid()); QCOMPARE(colorAt(0), colorAt(parsed->text.indexOf("continued"))); + QVERIFY(colorAt(0) != colorAt(parsed->text.indexOf("int"))); + QCOMPARE(formattedMessageText(index).data(), parsed.data()); + QCOMPARE(index.data(MsgTextRole).toString(), source); + model.setData(index, "```unknown_language\nint x;\n```", MsgTextRole); + const auto unknown = formattedMessageText(index); QVERIFY(unknown); QVERIFY(unknown->lightSyntax.isEmpty()); + model.setData(index, "```\nint x;\n```", MsgTextRole); + const auto plainCode = formattedMessageText(index); QVERIFY(plainCode); QVERIFY(plainCode->lightSyntax.isEmpty()); + model.setData(index, "**invitation**", MsgTextRole); model.setData(index, "utun://abc", MsgInviteLinkRole); + QVERIFY(!formattedMessageText(index)); + model.setData(index, QString(), MsgInviteLinkRole); model.setData(index, "poll", MsgContentTypeRole); + QVERIFY(!formattedMessageText(index)); + } + + void layoutPreservesTextAndThemeGeometry() { + QStandardItemModel model(1, 1); const auto index = model.index(0, 0); + model.setData(index, "**Текст 😀**\n```cpp\n\tint value = 123456789; // длинная строка\n\n```\n*конец*", MsgTextRole); + const auto parsed = formattedMessageText(index); QVERIFY(parsed); + const auto original = parsed->text; + for (int width : {80, 200, 600}) { + QTextLayout light, dark; light.setFont(QApplication::font()); dark.setFont(light.font()); + const qreal height = layoutFormattedMessage(light, *parsed, width, false); + QCOMPARE(layoutFormattedMessage(dark, *parsed, width, true), height); + QCOMPARE(light.lineCount(), dark.lineCount()); + QVERIFY(light.lineCount() >= 5); + for (int i = 0; i < light.lineCount(); ++i) { + QCOMPARE(light.lineAt(i).textStart(), dark.lineAt(i).textStart()); + QCOMPARE(light.lineAt(i).naturalTextWidth(), dark.lineAt(i).naturalTextWidth()); + QVERIFY(light.lineAt(i).y() + light.lineAt(i).height() <= height); + } + QCOMPARE(parsed->text, original); + } + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "message formatting: real C++ tokens, multiline state, theme geometry and cache verified"); + } +}; +QTEST_MAIN(MessageTextTest) +#include "test_message_text.moc"