chore: add tests for input highlighting (#6779)
Reviewed-by: Arne <78976058+4rneee@users.noreply.github.com> Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
+1
-1
@@ -86,7 +86,7 @@
|
|||||||
- Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606)
|
- Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606)
|
||||||
- Dev: Simplified uses of `getMessageSnapshot`. (#6607)
|
- Dev: Simplified uses of `getMessageSnapshot`. (#6607)
|
||||||
- Dev: Disabled `llvm-prefer-static-over-anonymous-namespace` in clang-tidy. (#6610)
|
- Dev: Disabled `llvm-prefer-static-over-anonymous-namespace` in clang-tidy. (#6610)
|
||||||
- Dev: Added experimental spell checker support. (#6446, #6703, #6722, #6730, #6731)
|
- Dev: Added experimental spell checker support. (#6446, #6703, #6722, #6730, #6731, #6779)
|
||||||
- Dev: Added Clazy linting in CI. (#6623)
|
- Dev: Added Clazy linting in CI. (#6623)
|
||||||
- Dev: Added custom clang-tidy module linting in CI. (#6626)
|
- Dev: Added custom clang-tidy module linting in CI. (#6626)
|
||||||
- Dev: CMake option `USE_ALTERNATE_LINKER` now errors if the given linker can't be found. (#6692)
|
- Dev: CMake option `USE_ALTERNATE_LINKER` now errors if the given linker can't be found. (#6692)
|
||||||
|
|||||||
@@ -69,12 +69,24 @@ bool isIgnoredWord(TwitchChannel *twitch, const QString &word)
|
|||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
|
namespace inputhighlight::detail {
|
||||||
|
|
||||||
|
// FIXME: this also matches URLs - this probably needs to be some function like Firefox' mozEnglishWordUtils::FindNextWord
|
||||||
|
QRegularExpression wordRegex()
|
||||||
|
{
|
||||||
|
static QRegularExpression regex{
|
||||||
|
R"(\p{L}(?:\P{Z}+\p{L}+)*)",
|
||||||
|
QRegularExpression::PatternOption::UseUnicodePropertiesOption,
|
||||||
|
};
|
||||||
|
return regex;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace inputhighlight::detail
|
||||||
|
|
||||||
InputHighlighter::InputHighlighter(SpellChecker &spellChecker, QObject *parent)
|
InputHighlighter::InputHighlighter(SpellChecker &spellChecker, QObject *parent)
|
||||||
: QSyntaxHighlighter(parent)
|
: QSyntaxHighlighter(parent)
|
||||||
, spellChecker(spellChecker)
|
, spellChecker(spellChecker)
|
||||||
// FIXME: this also matches URLs - this probably needs to be some function like Firefox' mozEnglishWordUtils::FindNextWord
|
, wordRegex(inputhighlight::detail::wordRegex())
|
||||||
, wordRegex(R"(\p{L}(?:\P{Z}+\p{L}+)*)",
|
|
||||||
QRegularExpression::PatternOption::UseUnicodePropertiesOption)
|
|
||||||
{
|
{
|
||||||
this->spellFmt.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
|
this->spellFmt.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
|
||||||
this->spellFmt.setUnderlineColor(Qt::red);
|
this->spellFmt.setUnderlineColor(Qt::red);
|
||||||
@@ -88,12 +100,36 @@ void InputHighlighter::setChannel(const std::shared_ptr<Channel> &channel)
|
|||||||
this->rehighlight();
|
this->rehighlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<QString> InputHighlighter::getSpellCheckedWords(const QString &text)
|
||||||
|
{
|
||||||
|
std::vector<QString> words;
|
||||||
|
this->visitWords(text, [&](const QString &word, qsizetype /*start*/,
|
||||||
|
qsizetype /*count*/) {
|
||||||
|
words.emplace_back(word);
|
||||||
|
});
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
void InputHighlighter::highlightBlock(const QString &text)
|
void InputHighlighter::highlightBlock(const QString &text)
|
||||||
{
|
{
|
||||||
if (!this->spellChecker.isLoaded())
|
if (!this->spellChecker.isLoaded())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this->visitWords(
|
||||||
|
text, [&](const QString &word, qsizetype start, qsizetype count) {
|
||||||
|
if (!this->spellChecker.check(word))
|
||||||
|
{
|
||||||
|
this->setFormat(static_cast<int>(start),
|
||||||
|
static_cast<int>(count), this->spellFmt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void InputHighlighter::visitWords(
|
||||||
|
const QString &text,
|
||||||
|
std::invocable<const QString &, qsizetype, qsizetype> auto &&cb)
|
||||||
|
{
|
||||||
auto *channel = this->channel.lock().get();
|
auto *channel = this->channel.lock().get();
|
||||||
|
|
||||||
QStringView textView = text;
|
QStringView textView = text;
|
||||||
@@ -112,11 +148,10 @@ void InputHighlighter::highlightBlock(const QString &text)
|
|||||||
{
|
{
|
||||||
auto match = it.next();
|
auto match = it.next();
|
||||||
auto text = match.captured();
|
auto text = match.captured();
|
||||||
if (!isIgnoredWord(channel, text) && !this->spellChecker.check(text))
|
if (!isIgnoredWord(channel, text))
|
||||||
{
|
{
|
||||||
this->setFormat(
|
cb(text, static_cast<int>(match.capturedStart() + cmdTriggerLen),
|
||||||
static_cast<int>(match.capturedStart() + cmdTriggerLen),
|
static_cast<int>(text.size()));
|
||||||
static_cast<int>(text.size()), this->spellFmt);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QSyntaxHighlighter>
|
#include <QSyntaxHighlighter>
|
||||||
|
|
||||||
|
#include <concepts>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
class QTextDocument;
|
class QTextDocument;
|
||||||
@@ -18,6 +19,12 @@ class Channel;
|
|||||||
class TwitchChannel;
|
class TwitchChannel;
|
||||||
class SpellChecker;
|
class SpellChecker;
|
||||||
|
|
||||||
|
namespace inputhighlight::detail {
|
||||||
|
|
||||||
|
QRegularExpression wordRegex();
|
||||||
|
|
||||||
|
} // namespace inputhighlight::detail
|
||||||
|
|
||||||
/// This highlights the text in the split input.
|
/// This highlights the text in the split input.
|
||||||
/// Currently, it only does spell checking.
|
/// Currently, it only does spell checking.
|
||||||
class InputHighlighter : public QSyntaxHighlighter
|
class InputHighlighter : public QSyntaxHighlighter
|
||||||
@@ -32,10 +39,20 @@ public:
|
|||||||
|
|
||||||
void setChannel(const std::shared_ptr<Channel> &channel);
|
void setChannel(const std::shared_ptr<Channel> &channel);
|
||||||
|
|
||||||
|
/// Do a pass over the whole text and filter out all words that will be
|
||||||
|
/// checked by the spell checker.
|
||||||
|
std::vector<QString> getSpellCheckedWords(const QString &text);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void highlightBlock(const QString &text) override;
|
void highlightBlock(const QString &text) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
/// Visit all words that are not ignored
|
||||||
|
void visitWords(
|
||||||
|
const QString &text,
|
||||||
|
std::invocable</*word=*/const QString &, /*start=*/qsizetype,
|
||||||
|
/*count=*/qsizetype> auto &&cb);
|
||||||
|
|
||||||
SpellChecker &spellChecker;
|
SpellChecker &spellChecker;
|
||||||
QTextCharFormat spellFmt;
|
QTextCharFormat spellFmt;
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ set(test_SOURCES
|
|||||||
${CMAKE_CURRENT_LIST_DIR}/src/TwitchChannel.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/TwitchChannel.cpp
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/TwitchUserColor.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/TwitchUserColor.cpp
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/FunctionRef.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/FunctionRef.cpp
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}/src/InputHighlighter.cpp
|
||||||
|
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp
|
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
#include "widgets/splits/InputHighlighter.hpp"
|
||||||
|
|
||||||
|
#include "controllers/accounts/AccountController.hpp"
|
||||||
|
#include "controllers/commands/Command.hpp"
|
||||||
|
#include "controllers/commands/CommandController.hpp"
|
||||||
|
#include "controllers/highlights/HighlightController.hpp"
|
||||||
|
#include "controllers/ignores/IgnorePhrase.hpp"
|
||||||
|
#include "controllers/spellcheck/SpellChecker.hpp"
|
||||||
|
#include "messages/Emote.hpp"
|
||||||
|
#include "mocks/BaseApplication.hpp"
|
||||||
|
#include "mocks/EmoteController.hpp"
|
||||||
|
#include "mocks/TwitchIrcServer.hpp"
|
||||||
|
#include "providers/twitch/IrcMessageHandler.hpp"
|
||||||
|
#include "providers/twitch/TwitchAccount.hpp"
|
||||||
|
#include "providers/twitch/TwitchBadges.hpp"
|
||||||
|
#include "providers/twitch/TwitchChannel.hpp"
|
||||||
|
#include "Test.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringBuilder>
|
||||||
|
|
||||||
|
using namespace chatterino;
|
||||||
|
using namespace Qt::Literals;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class MockApplication : public mock::BaseApplication
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
MockApplication()
|
||||||
|
: commandController(this->paths_)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
EmoteController *getEmotes() override
|
||||||
|
{
|
||||||
|
return &this->emotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountController *getAccounts() override
|
||||||
|
{
|
||||||
|
return &this->accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
ITwitchIrcServer *getTwitch() override
|
||||||
|
{
|
||||||
|
return &this->twitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
BttvEmotes *getBttvEmotes() override
|
||||||
|
{
|
||||||
|
return &this->bttvEmotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
FfzEmotes *getFfzEmotes() override
|
||||||
|
{
|
||||||
|
return &this->ffzEmotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
SeventvEmotes *getSeventvEmotes() override
|
||||||
|
{
|
||||||
|
return &this->seventvEmotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandController *getCommands() override
|
||||||
|
{
|
||||||
|
return &this->commandController;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountController accounts;
|
||||||
|
mock::EmoteController emotes;
|
||||||
|
mock::MockTwitchIrcServer twitch;
|
||||||
|
BttvEmotes bttvEmotes;
|
||||||
|
FfzEmotes ffzEmotes;
|
||||||
|
SeventvEmotes seventvEmotes;
|
||||||
|
CommandController commandController;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::pair<const EmoteName, EmotePtr> makeEmote(const QString &name)
|
||||||
|
{
|
||||||
|
EmoteName emoteName{name};
|
||||||
|
auto ptr = std::make_shared<Emote>(Emote{.name = emoteName});
|
||||||
|
return {emoteName, ptr};
|
||||||
|
}
|
||||||
|
|
||||||
|
using EmoteMapPtr = std::shared_ptr<const EmoteMap>;
|
||||||
|
|
||||||
|
EmoteMapPtr makeEmotes(auto &&...emotes)
|
||||||
|
{
|
||||||
|
auto map = std::make_shared<EmoteMap>();
|
||||||
|
((map->emplace(makeEmote(std::forward<decltype(emotes)>(emotes)))), ...);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockEmotes {
|
||||||
|
EmoteMapPtr seventv;
|
||||||
|
EmoteMapPtr bttv;
|
||||||
|
EmoteMapPtr ffz;
|
||||||
|
EmoteMapPtr twitchAccount;
|
||||||
|
|
||||||
|
static MockEmotes channel()
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
.seventv = makeEmotes(u"7TVEmote"_s, u"PogChamp"_s),
|
||||||
|
.bttv = makeEmotes(u"BTTVEmote"_s, u"Kappa"_s),
|
||||||
|
.ffz = makeEmotes(u"FFZEmote"_s, u"Keepo"_s),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static MockEmotes global()
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
.seventv = makeEmotes(u"7TVGlobal"_s),
|
||||||
|
.bttv = makeEmotes(u"BTTVGlobal"_s),
|
||||||
|
.ffz = makeEmotes(u"FFZGlobal"_s),
|
||||||
|
.twitchAccount = makeEmotes(u"MyCoolTwitchEmote"_s),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::shared_ptr<TwitchChannel> makeMockTwitchChannel(const QString &name)
|
||||||
|
{
|
||||||
|
auto chan = std::make_shared<TwitchChannel>(name);
|
||||||
|
auto mocks = MockEmotes::channel();
|
||||||
|
chan->setSeventvEmotes(std::move(mocks.seventv));
|
||||||
|
chan->setBttvEmotes(std::move(mocks.bttv));
|
||||||
|
chan->setFfzEmotes(std::move(mocks.ffz));
|
||||||
|
|
||||||
|
chan->addRecentChatter("UserChatter");
|
||||||
|
chan->addRecentChatter("UserColor");
|
||||||
|
chan->addRecentChatter("MyUser42");
|
||||||
|
chan->addRecentChatter("123kappa123");
|
||||||
|
|
||||||
|
return chan;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class InputHighlighterTest : public ::testing::Test
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
this->mockApplication = std::make_unique<MockApplication>();
|
||||||
|
auto mocks = MockEmotes::global();
|
||||||
|
this->mockApplication->seventvEmotes.setGlobalEmotes(mocks.seventv);
|
||||||
|
this->mockApplication->bttvEmotes.setEmotes(mocks.bttv);
|
||||||
|
this->mockApplication->ffzEmotes.setEmotes(mocks.ffz);
|
||||||
|
this->mockApplication->getAccounts()->twitch.getCurrent()->setEmotes(
|
||||||
|
mocks.twitchAccount);
|
||||||
|
this->channel = makeMockTwitchChannel(u"twitchdev"_s);
|
||||||
|
this->mockApplication->commandController.items.append(
|
||||||
|
Command("/my-command", ""));
|
||||||
|
this->mockApplication->commandController.items.append(
|
||||||
|
Command("command space", ""));
|
||||||
|
this->mockApplication->commandController.items.append(
|
||||||
|
Command("command with more than one space", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TearDown() override
|
||||||
|
{
|
||||||
|
this->channel.reset();
|
||||||
|
this->mockApplication.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<TwitchChannel> channel;
|
||||||
|
std::unique_ptr<MockApplication> mockApplication;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(InputHighlighterTest, getSpellCheckedWords)
|
||||||
|
{
|
||||||
|
struct Case {
|
||||||
|
QString input;
|
||||||
|
std::vector<QString> words;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<Case> cases{
|
||||||
|
{.input = "", .words = {}},
|
||||||
|
{.input = "word", .words = {"word"}},
|
||||||
|
{.input = "word word", .words = {"word", "word"}},
|
||||||
|
{.input = " word word ", .words = {"word", "word"}},
|
||||||
|
{.input = "word?", .words = {"word"}},
|
||||||
|
{.input = "word?word", .words = {"word?word"}},
|
||||||
|
{.input = "word-word", .words = {"word-word"}},
|
||||||
|
{
|
||||||
|
.input = "channel emotes 7TVEmote a BTTVEmote b FFZEmote c",
|
||||||
|
.words = {"channel", "emotes", "TVEmote", "a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "global emotes 7TVGlobal a BTTVGlobal b FFZGlobal c "
|
||||||
|
"MyCoolTwitchEmote d",
|
||||||
|
// FIXME: TVGlobal shouldn't show up
|
||||||
|
.words = {"global", "emotes", "TVGlobal", "a", "b", "c", "d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "/ban a user",
|
||||||
|
.words = {"a", "user"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "/my-command some text",
|
||||||
|
.words = {"some", "text"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "command space and some text",
|
||||||
|
.words = {"and", "some", "text"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "command with more than one space and some text",
|
||||||
|
.words = {"and", "some", "text"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "command with more than one and some text",
|
||||||
|
.words =
|
||||||
|
{
|
||||||
|
"command",
|
||||||
|
"with",
|
||||||
|
"more",
|
||||||
|
"than",
|
||||||
|
"one",
|
||||||
|
"and",
|
||||||
|
"some",
|
||||||
|
"text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.input = "Hey, @userchatter a 123kappa123 b MyUser42 c",
|
||||||
|
// FIXME: 'kappa' and 'MyUser' shouldn't show up
|
||||||
|
.words = {"Hey", "a", "kappa", "b", "MyUser", "c"},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
SpellChecker nullSpellChecker;
|
||||||
|
InputHighlighter highlighter(nullSpellChecker, nullptr);
|
||||||
|
highlighter.setChannel(this->channel);
|
||||||
|
for (size_t i = 0; i < cases.size(); i++)
|
||||||
|
{
|
||||||
|
const auto &c = cases[i];
|
||||||
|
auto got = highlighter.getSpellCheckedWords(c.input);
|
||||||
|
ASSERT_EQ(got, c.words) << "index=" << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(InputHighlight, wordRegex)
|
||||||
|
{
|
||||||
|
struct Case {
|
||||||
|
QStringView input;
|
||||||
|
std::vector<QStringView> words;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<Case> cases{
|
||||||
|
{.input = u"", .words = {}},
|
||||||
|
{.input = u"word", .words = {u"word"}},
|
||||||
|
{.input = u"word word", .words = {u"word", u"word"}},
|
||||||
|
{.input = u" word word ", .words = {u"word", u"word"}},
|
||||||
|
{.input = u"word?", .words = {u"word"}},
|
||||||
|
{.input = u"word?word", .words = {u"word?word"}},
|
||||||
|
{.input = u"a12word", .words = {u"a12word"}},
|
||||||
|
{.input = u"äwördü", .words = {u"äwördü"}},
|
||||||
|
// FIXME: should be the whole input
|
||||||
|
{.input = u"123kappa123", .words = {u"kappa"}},
|
||||||
|
{.input = u"123kappa", .words = {u"kappa"}},
|
||||||
|
{.input = u"kappa123", .words = {u"kappa"}},
|
||||||
|
{.input = u"abc @foo bar@baz@", .words = {u"abc", u"foo", u"bar@baz"}},
|
||||||
|
{.input = u"1234567 word a123", .words = {u"word", u"a"}},
|
||||||
|
};
|
||||||
|
|
||||||
|
auto re = inputhighlight::detail::wordRegex();
|
||||||
|
for (size_t i = 0; i < cases.size(); i++)
|
||||||
|
{
|
||||||
|
const auto &c = cases[i];
|
||||||
|
std::vector<QStringView> got;
|
||||||
|
auto match = re.globalMatchView(c.input);
|
||||||
|
while (match.hasNext())
|
||||||
|
{
|
||||||
|
got.emplace_back(match.next().capturedView());
|
||||||
|
}
|
||||||
|
ASSERT_EQ(got, c.words) << "index=" << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user