diff --git a/doc/message_links.md b/doc/message_links.md index 624a0e78..276465c3 100644 --- a/doc/message_links.md +++ b/doc/message_links.md @@ -22,6 +22,10 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`, где end исключён. Единицы обоих API — UTF-16: `tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`. +Конец диапазона Android не включён в ссылку; Qt возвращает начало и длину. +Удаление внешней пунктуации занимает линейное время: баланс трёх видов скобок +считается один раз, хвост сокращается по индексу без повторного копирования адреса. +Порт веб-адреса пустой либо десятичный от 0 до 65535; знак перед числом недопустим. Подсветка и нажатие используют эти диапазоны. Android подставляет emoji placeholders только в обычный текст между ссылками: emoji внутри адреса остаётся частью полного URL. Qt использует один QTextLayout для размеров, отрисовки, выделения и координат нажатия. @@ -38,11 +42,14 @@ Qt `QDesktopServices::openUrl`. На нажатии веб-адрес снова Общие случаи: `tools/chatgui/tests/message_links_cases.tsv`. Qt `test_invite_links` проверяет разбор, перенесённые веб-ссылки, смесь схем, нажатие, drag/copy и сокращённые приглашения; `test_emoji_playback` — жесты приглашений. -Android `tests/test_message_links.kt` проверяет тот же TSV на классах из APK-сборки: +Android `MessageLinksTest` проверяет тот же TSV через Gradle, некорректные authority +и длинный хвост пунктуации. Аналогичный тест длинного хвоста есть в Qt. +Оба разбора не открывают ссылки; некорректный пользовательский текст не создаёт логов. ```bash cd tools/chatgui-android -./build.sh noinstall -kotlinc tests/test_message_links.kt -classpath app/build/tmp/kotlin-classes/debug -d /tmp/message-links-tests.jar -kotlin -classpath /tmp/message-links-tests.jar:app/build/tmp/kotlin-classes/debug Test_message_linksKt ../chatgui/tests/message_links_cases.tsv +./gradlew :app:testDebugUnitTest --tests com.utun.chat.data.MessageLinksTest ``` + +`tests/test_message_links.kt` остаётся отдельным CLI-тестом того же TSV; +ему передаётся путь к файлу, а в classpath нужны собранные классы `MessageLinks`. diff --git a/tools/chatgui/src/message_links.cpp b/tools/chatgui/src/message_links.cpp index cbe988e5..e74a6b0f 100644 --- a/tools/chatgui/src/message_links.cpp +++ b/tools/chatgui/src/message_links.cpp @@ -12,13 +12,24 @@ QVector messageLinks(const QString& text) { const auto match = matches.next(); QString url = match.captured(); if (!url.startsWith(QStringLiteral("utun://"))) { - while (!url.isEmpty()) { - const QChar last = url.back(); - const int closing = QStringLiteral(")]}").indexOf(last); - const bool unmatched = closing >= 0 && url.count(last) > url.count(QStringLiteral("([{").at(closing)); - if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; - url.chop(1); + // Один подсчёт и один проход по хвосту: повторный count() на каждой скобке даёт O(n²). + qsizetype balance[3] = {}; + for (const QChar ch : url) { + const int opening = QStringLiteral("([{").indexOf(ch); + const int closing = QStringLiteral(")]}").indexOf(ch); + if (opening >= 0) balance[opening]--; + if (closing >= 0) balance[closing]++; } + auto end = url.size(); + while (end > 0) { + const QChar last = url.at(end - 1); + const int closing = QStringLiteral(")]}").indexOf(last); + const bool unmatched = closing >= 0 && balance[closing] > 0; + if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; + if (closing >= 0) balance[closing]--; + end--; + } + url.truncate(end); const QUrl parsed(url, QUrl::StrictMode); if (!parsed.isValid() || parsed.host().isEmpty()) continue; } diff --git a/tools/chatgui/tests/test_invite_links.cpp b/tools/chatgui/tests/test_invite_links.cpp index 2ebec6a6..b34eb6bf 100644 --- a/tools/chatgui/tests/test_invite_links.cpp +++ b/tools/chatgui/tests/test_invite_links.cpp @@ -259,6 +259,22 @@ private slots: QVERIFY(count >= 15); } + void longMessageLinkTail() { + const QString first = QStringLiteral("https://example.com/a([0])"); + const QString second = QStringLiteral("http://example.org/b"); + const QString text = QString::fromUtf8("👋 ") + first + QStringLiteral(")]},.!?").repeated(20000) + ' ' + second; + QElapsedTimer timer; timer.start(); + const auto links = messageLinks(text); + const auto elapsed = timer.elapsed(); + qInfo() << "Message links:" << text.size() << "UTF-16 units parsed in" << elapsed << "ms"; + QCOMPARE(links.size(), 2); + QCOMPARE(links.first().url, first); + QCOMPARE(links.first().start, 3); + QCOMPARE(links.last().url, second); + QCOMPARE(text.mid(links.last().start, links.last().length), second); + QVERIFY2(elapsed < 5000, "Message link parsing exceeded the UI regression guard"); + } + void webLinksUsePaintedCoordinatesAndDragDoesNotOpen() { const QString first = QStringLiteral("https://пример.рф/путь?") + QString(160, 'a') + QStringLiteral("=1#fragment"); const QString second = QStringLiteral("http://example.org/a(b)?x=2");