Add input completion test suite (#4644)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel Sage 2023-05-21 06:10:49 -04:00 committed by GitHub
parent e9f300b765
commit 51f2c4d1c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 514 additions and 63 deletions

View file

@ -5,6 +5,7 @@
- Minor: Added `/shoutout <username>` commands to shoutout specified user. (#4638) - Minor: Added `/shoutout <username>` commands to shoutout specified user. (#4638)
- Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) - Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637)
- Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570) - Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570)
- Dev: Added test cases for emote and tab completion. (#4644)
## 2.4.4 ## 2.4.4

View file

@ -13,7 +13,7 @@ int main(int argc, char **argv)
::benchmark::Initialize(&argc, argv); ::benchmark::Initialize(&argc, argv);
// Ensure settings are initialized before any tests are run // Ensure settings are initialized before any tests are run
chatterino::Settings settings("/tmp/c2-empty-test"); chatterino::Settings settings("/tmp/c2-empty-mock");
QtConcurrent::run([&app] { QtConcurrent::run([&app] {
::benchmark::RunSpecifiedBenchmarks(); ::benchmark::RunSpecifiedBenchmarks();

View file

@ -57,7 +57,7 @@ public:
return nullptr; return nullptr;
} }
TwitchIrcServer *getTwitch() override ITwitchIrcServer *getTwitch() override
{ {
return nullptr; return nullptr;
} }

View file

@ -245,6 +245,11 @@ IUserDataController *Application::getUserData()
return this->userData; return this->userData;
} }
ITwitchIrcServer *Application::getTwitch()
{
return this->twitch;
}
void Application::save() void Application::save()
{ {
for (auto &singleton : this->singletons_) for (auto &singleton : this->singletons_)

View file

@ -10,6 +10,7 @@
namespace chatterino { namespace chatterino {
class TwitchIrcServer; class TwitchIrcServer;
class ITwitchIrcServer;
class PubSub; class PubSub;
class CommandController; class CommandController;
@ -55,7 +56,7 @@ public:
virtual CommandController *getCommands() = 0; virtual CommandController *getCommands() = 0;
virtual HighlightController *getHighlights() = 0; virtual HighlightController *getHighlights() = 0;
virtual NotificationController *getNotifications() = 0; virtual NotificationController *getNotifications() = 0;
virtual TwitchIrcServer *getTwitch() = 0; virtual ITwitchIrcServer *getTwitch() = 0;
virtual ChatterinoBadges *getChatterinoBadges() = 0; virtual ChatterinoBadges *getChatterinoBadges() = 0;
virtual FfzBadges *getFfzBadges() = 0; virtual FfzBadges *getFfzBadges() = 0;
virtual IUserDataController *getUserData() = 0; virtual IUserDataController *getUserData() = 0;
@ -141,10 +142,7 @@ public:
{ {
return this->highlights; return this->highlights;
} }
TwitchIrcServer *getTwitch() override ITwitchIrcServer *getTwitch() override;
{
return this->twitch;
}
ChatterinoBadges *getChatterinoBadges() override ChatterinoBadges *getChatterinoBadges() override
{ {
return this->chatterinoBadges; return this->chatterinoBadges;

View file

@ -92,6 +92,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
return; return;
} }
auto *app = getIApp();
// Twitch channel // Twitch channel
auto *tc = dynamic_cast<TwitchChannel *>(&this->channel_); auto *tc = dynamic_cast<TwitchChannel *>(&this->channel_);
@ -130,7 +131,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
} }
}; };
if (auto account = getApp()->accounts->twitch.getCurrent()) if (auto account = app->getAccounts()->twitch.getCurrent())
{ {
// Twitch Emotes available globally // Twitch Emotes available globally
for (const auto &emote : account->accessEmotes()->emotes) for (const auto &emote : account->accessEmotes()->emotes)
@ -153,18 +154,18 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
// 7TV Global // 7TV Global
for (const auto &emote : for (const auto &emote :
*getApp()->twitch->getSeventvEmotes().globalEmotes()) *app->getTwitch()->getSeventvEmotes().globalEmotes())
{ {
addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote); addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote);
} }
// Bttv Global // Bttv Global
for (const auto &emote : *getApp()->twitch->getBttvEmotes().emotes()) for (const auto &emote : *app->getTwitch()->getBttvEmotes().emotes())
{ {
addString(emote.first.string, TaggedString::Type::BTTVChannelEmote); addString(emote.first.string, TaggedString::Type::BTTVChannelEmote);
} }
// Ffz Global // Ffz Global
for (const auto &emote : *getApp()->twitch->getFfzEmotes().emotes()) for (const auto &emote : *app->getTwitch()->getFfzEmotes().emotes())
{ {
addString(emote.first.string, TaggedString::Type::FFZChannelEmote); addString(emote.first.string, TaggedString::Type::FFZChannelEmote);
} }
@ -172,7 +173,8 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
// Emojis // Emojis
if (prefix.startsWith(":")) if (prefix.startsWith(":"))
{ {
const auto &emojiShortCodes = getApp()->emotes->emojis.shortCodes; const auto &emojiShortCodes =
app->getEmotes()->getEmojis()->getShortCodes();
for (const auto &m : emojiShortCodes) for (const auto &m : emojiShortCodes)
{ {
addString(QString(":%1:").arg(m), TaggedString::Type::Emoji); addString(QString(":%1:").arg(m), TaggedString::Type::Emoji);
@ -231,20 +233,20 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote);
} }
#ifdef CHATTERINO_HAVE_PLUGINS #ifdef CHATTERINO_HAVE_PLUGINS
for (const auto &command : getApp()->commands->pluginCommands()) for (const auto &command : app->getCommands()->pluginCommands())
{ {
addString(command, TaggedString::PluginCommand); addString(command, TaggedString::PluginCommand);
} }
#endif #endif
// Custom Chatterino commands // Custom Chatterino commands
for (const auto &command : getApp()->commands->items) for (const auto &command : app->getCommands()->items)
{ {
addString(command.name, TaggedString::CustomCommand); addString(command.name, TaggedString::CustomCommand);
} }
// Default Chatterino commands // Default Chatterino commands
for (const auto &command : for (const auto &command :
getApp()->commands->getDefaultChatterinoCommandList()) app->getCommands()->getDefaultChatterinoCommandList())
{ {
addString(command, TaggedString::ChatterinoCommand); addString(command, TaggedString::ChatterinoCommand);
} }
@ -256,6 +258,19 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
} }
} }
std::vector<QString> CompletionModel::allItems() const
{
std::shared_lock lock(this->itemsMutex_);
std::vector<QString> results;
results.reserve(this->items_.size());
for (const auto &item : this->items_)
{
results.push_back(item.string);
}
return results;
}
bool CompletionModel::compareStrings(const QString &a, const QString &b) bool CompletionModel::compareStrings(const QString &a, const QString &b)
{ {
// try comparing insensitively, if they are the same then senstively // try comparing insensitively, if they are the same then senstively

View file

@ -6,6 +6,8 @@
#include <set> #include <set>
#include <shared_mutex> #include <shared_mutex>
class InputCompletionTest;
namespace chatterino { namespace chatterino {
class Channel; class Channel;
@ -60,10 +62,14 @@ public:
static bool compareStrings(const QString &a, const QString &b); static bool compareStrings(const QString &a, const QString &b);
private: private:
std::vector<QString> allItems() const;
mutable std::shared_mutex itemsMutex_; mutable std::shared_mutex itemsMutex_;
std::set<TaggedString> items_; std::set<TaggedString> items_;
Channel &channel_; Channel &channel_;
friend class ::InputCompletionTest;
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -193,7 +193,7 @@ void BttvEmotes::loadEmotes()
{ {
if (!Settings::instance().enableBTTVGlobalEmotes) if (!Settings::instance().enableBTTVGlobalEmotes)
{ {
this->global_.set(EMPTY_EMOTE_MAP); this->setEmotes(EMPTY_EMOTE_MAP);
return; return;
} }
@ -203,13 +203,18 @@ void BttvEmotes::loadEmotes()
auto emotes = this->global_.get(); auto emotes = this->global_.get();
auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes); auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes);
if (pair.first) if (pair.first)
this->global_.set( this->setEmotes(
std::make_shared<EmoteMap>(std::move(pair.second))); std::make_shared<EmoteMap>(std::move(pair.second)));
return pair.first; return pair.first;
}) })
.execute(); .execute();
} }
void BttvEmotes::setEmotes(std::shared_ptr<const EmoteMap> emotes)
{
this->global_.set(std::move(emotes));
}
void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel, void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel,
const QString &channelId, const QString &channelId,
const QString &channelDisplayName, const QString &channelDisplayName,

View file

@ -29,6 +29,7 @@ public:
std::shared_ptr<const EmoteMap> emotes() const; std::shared_ptr<const EmoteMap> emotes() const;
boost::optional<EmotePtr> emote(const EmoteName &name) const; boost::optional<EmotePtr> emote(const EmoteName &name) const;
void loadEmotes(); void loadEmotes();
void setEmotes(std::shared_ptr<const EmoteMap> emotes);
static void loadChannel(std::weak_ptr<Channel> channel, static void loadChannel(std::weak_ptr<Channel> channel,
const QString &channelId, const QString &channelId,
const QString &channelDisplayName, const QString &channelDisplayName,

View file

@ -265,7 +265,7 @@ void Emojis::loadEmojiSet()
} }
std::vector<boost::variant<EmotePtr, QString>> Emojis::parse( std::vector<boost::variant<EmotePtr, QString>> Emojis::parse(
const QString &text) const QString &text) const
{ {
auto result = std::vector<boost::variant<EmotePtr, QString>>(); auto result = std::vector<boost::variant<EmotePtr, QString>>();
int lastParsedEmojiEndIndex = 0; int lastParsedEmojiEndIndex = 0;
@ -359,7 +359,7 @@ std::vector<boost::variant<EmotePtr, QString>> Emojis::parse(
return result; return result;
} }
QString Emojis::replaceShortCodes(const QString &text) QString Emojis::replaceShortCodes(const QString &text) const
{ {
QString ret(text); QString ret(text);
auto it = this->findShortCodesRegex_.globalMatch(text); auto it = this->findShortCodesRegex_.globalMatch(text);
@ -393,4 +393,14 @@ QString Emojis::replaceShortCodes(const QString &text)
return ret; return ret;
} }
const EmojiMap &Emojis::getEmojis() const
{
return this->emojis;
}
const std::vector<QString> &Emojis::getShortCodes() const
{
return this->shortCodes;
}
} // namespace chatterino } // namespace chatterino

View file

@ -37,16 +37,32 @@ struct EmojiData {
using EmojiMap = ConcurrentMap<QString, std::shared_ptr<EmojiData>>; using EmojiMap = ConcurrentMap<QString, std::shared_ptr<EmojiData>>;
class Emojis class IEmojis
{
public:
virtual ~IEmojis() = default;
virtual std::vector<boost::variant<EmotePtr, QString>> parse(
const QString &text) const = 0;
virtual const EmojiMap &getEmojis() const = 0;
virtual const std::vector<QString> &getShortCodes() const = 0;
virtual QString replaceShortCodes(const QString &text) const = 0;
};
class Emojis : public IEmojis
{ {
public: public:
void initialize(); void initialize();
void load(); void load();
std::vector<boost::variant<EmotePtr, QString>> parse(const QString &text); std::vector<boost::variant<EmotePtr, QString>> parse(
const QString &text) const override;
EmojiMap emojis; EmojiMap emojis;
std::vector<QString> shortCodes; std::vector<QString> shortCodes;
QString replaceShortCodes(const QString &text); QString replaceShortCodes(const QString &text) const override;
const EmojiMap &getEmojis() const override;
const std::vector<QString> &getShortCodes() const override;
private: private:
void loadEmojis(); void loadEmojis();

View file

@ -188,7 +188,7 @@ void FfzEmotes::loadEmotes()
{ {
if (!Settings::instance().enableFFZGlobalEmotes) if (!Settings::instance().enableFFZGlobalEmotes)
{ {
this->global_.set(EMPTY_EMOTE_MAP); this->setEmotes(EMPTY_EMOTE_MAP);
return; return;
} }
@ -199,13 +199,18 @@ void FfzEmotes::loadEmotes()
.timeout(30000) .timeout(30000)
.onSuccess([this](auto result) -> Outcome { .onSuccess([this](auto result) -> Outcome {
auto parsedSet = parseGlobalEmotes(result.parseJson()); auto parsedSet = parseGlobalEmotes(result.parseJson());
this->global_.set(std::make_shared<EmoteMap>(std::move(parsedSet))); this->setEmotes(std::make_shared<EmoteMap>(std::move(parsedSet)));
return Success; return Success;
}) })
.execute(); .execute();
} }
void FfzEmotes::setEmotes(std::shared_ptr<const EmoteMap> emotes)
{
this->global_.set(std::move(emotes));
}
void FfzEmotes::loadChannel( void FfzEmotes::loadChannel(
std::weak_ptr<Channel> channel, const QString &channelID, std::weak_ptr<Channel> channel, const QString &channelID,
std::function<void(EmoteMap &&)> emoteCallback, std::function<void(EmoteMap &&)> emoteCallback,

View file

@ -22,6 +22,7 @@ public:
std::shared_ptr<const EmoteMap> emotes() const; std::shared_ptr<const EmoteMap> emotes() const;
boost::optional<EmotePtr> emote(const EmoteName &name) const; boost::optional<EmotePtr> emote(const EmoteName &name) const;
void loadEmotes(); void loadEmotes();
void setEmotes(std::shared_ptr<const EmoteMap> emotes);
static void loadChannel( static void loadChannel(
std::weak_ptr<Channel> channel, const QString &channelId, std::weak_ptr<Channel> channel, const QString &channelId,
std::function<void(EmoteMap &&)> emoteCallback, std::function<void(EmoteMap &&)> emoteCallback,

View file

@ -275,7 +275,7 @@ void SeventvEmotes::loadGlobalEmotes()
{ {
if (!Settings::instance().enableSevenTVGlobalEmotes) if (!Settings::instance().enableSevenTVGlobalEmotes)
{ {
this->global_.set(EMPTY_EMOTE_MAP); this->setGlobalEmotes(EMPTY_EMOTE_MAP);
return; return;
} }
@ -289,7 +289,8 @@ void SeventvEmotes::loadGlobalEmotes()
auto emoteMap = parseEmotes(parsedEmotes, true); auto emoteMap = parseEmotes(parsedEmotes, true);
qCDebug(chatterinoSeventv) qCDebug(chatterinoSeventv)
<< "Loaded" << emoteMap.size() << "7TV Global Emotes"; << "Loaded" << emoteMap.size() << "7TV Global Emotes";
this->global_.set(std::make_shared<EmoteMap>(std::move(emoteMap))); this->setGlobalEmotes(
std::make_shared<EmoteMap>(std::move(emoteMap)));
return Success; return Success;
}) })
@ -300,6 +301,11 @@ void SeventvEmotes::loadGlobalEmotes()
.execute(); .execute();
} }
void SeventvEmotes::setGlobalEmotes(std::shared_ptr<const EmoteMap> emotes)
{
this->global_.set(std::move(emotes));
}
void SeventvEmotes::loadChannelEmotes( void SeventvEmotes::loadChannelEmotes(
const std::weak_ptr<Channel> &channel, const QString &channelId, const std::weak_ptr<Channel> &channel, const QString &channelId,
std::function<void(EmoteMap &&, ChannelInfo)> callback, bool manualRefresh) std::function<void(EmoteMap &&, ChannelInfo)> callback, bool manualRefresh)

View file

@ -75,6 +75,7 @@ public:
std::shared_ptr<const EmoteMap> globalEmotes() const; std::shared_ptr<const EmoteMap> globalEmotes() const;
boost::optional<EmotePtr> globalEmote(const EmoteName &name) const; boost::optional<EmotePtr> globalEmote(const EmoteName &name) const;
void loadGlobalEmotes(); void loadGlobalEmotes();
void setGlobalEmotes(std::shared_ptr<const EmoteMap> emotes);
static void loadChannelEmotes( static void loadChannelEmotes(
const std::weak_ptr<Channel> &channel, const QString &channelId, const std::weak_ptr<Channel> &channel, const QString &channelId,
std::function<void(EmoteMap &&, ChannelInfo)> callback, std::function<void(EmoteMap &&, ChannelInfo)> callback,

View file

@ -23,7 +23,21 @@ class TwitchChannel;
class BttvLiveUpdates; class BttvLiveUpdates;
class SeventvEventAPI; class SeventvEventAPI;
class TwitchIrcServer final : public AbstractIrcServer, public Singleton class ITwitchIrcServer
{
public:
virtual ~ITwitchIrcServer() = default;
virtual const BttvEmotes &getBttvEmotes() const = 0;
virtual const FfzEmotes &getFfzEmotes() const = 0;
virtual const SeventvEmotes &getSeventvEmotes() const = 0;
// Update this interface with TwitchIrcServer methods as needed
};
class TwitchIrcServer final : public AbstractIrcServer,
public Singleton,
public ITwitchIrcServer
{ {
public: public:
TwitchIrcServer(); TwitchIrcServer();
@ -70,9 +84,9 @@ public:
std::unique_ptr<BttvLiveUpdates> bttvLiveUpdates; std::unique_ptr<BttvLiveUpdates> bttvLiveUpdates;
std::unique_ptr<SeventvEventAPI> seventvEventAPI; std::unique_ptr<SeventvEventAPI> seventvEventAPI;
const BttvEmotes &getBttvEmotes() const; const BttvEmotes &getBttvEmotes() const override;
const FfzEmotes &getFfzEmotes() const; const FfzEmotes &getFfzEmotes() const override;
const SeventvEmotes &getSeventvEmotes() const; const SeventvEmotes &getSeventvEmotes() const override;
protected: protected:
virtual void initializeConnection(IrcConnection *connection, virtual void initializeConnection(IrcConnection *connection,

View file

@ -12,7 +12,7 @@ If you're adding support for a new endpoint, these are the things you should kno
1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible. 1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible.
1. Override the virtual function in the `Helix` class. 1. Override the virtual function in the `Helix` class.
1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file. 1. Mock the function in the `mock::Helix` class in the `mocks/include/mocks/Helix.hpp` file.
1. (Optional) Make a new error enum for the failure callback. 1. (Optional) Make a new error enum for the failure callback.
For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`. For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`.

View file

@ -16,6 +16,7 @@ public:
virtual ~IEmotes() = default; virtual ~IEmotes() = default;
virtual ITwitchEmotes *getTwitchEmotes() = 0; virtual ITwitchEmotes *getTwitchEmotes() = 0;
virtual IEmojis *getEmojis() = 0;
}; };
class Emotes final : public IEmotes, public Singleton class Emotes final : public IEmotes, public Singleton
@ -32,6 +33,11 @@ public:
return &this->twitch; return &this->twitch;
} }
IEmojis *getEmojis() final
{
return &this->emojis;
}
TwitchEmotes twitch; TwitchEmotes twitch;
Emojis emojis; Emojis emojis;

View file

@ -17,12 +17,7 @@
namespace { namespace {
using namespace chatterino; using namespace chatterino;
using namespace chatterino::detail;
struct CompletionEmote {
EmotePtr emote;
QString displayName;
QString providerName;
};
void addEmotes(std::vector<CompletionEmote> &out, const EmoteMap &map, void addEmotes(std::vector<CompletionEmote> &out, const EmoteMap &map,
const QString &text, const QString &providerName) const QString &text, const QString &providerName)
@ -53,33 +48,18 @@ void addEmojis(std::vector<CompletionEmote> &out, const EmojiMap &map,
} // namespace } // namespace
namespace chatterino { namespace chatterino::detail {
InputCompletionPopup::InputCompletionPopup(QWidget *parent) std::vector<CompletionEmote> buildCompletionEmoteList(const QString &text,
: BasePopup({BasePopup::EnableCustomFrame, BasePopup::Frameless, ChannelPtr channel)
BasePopup::DontFocus, BaseWindow::DisableLayoutSave},
parent)
, model_(this)
{
this->initLayout();
QObject::connect(&this->redrawTimer_, &QTimer::timeout, this, [this] {
if (this->isVisible())
{
this->ui_.listView->doItemsLayout();
}
});
this->redrawTimer_.setInterval(33);
}
void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
{ {
std::vector<CompletionEmote> emotes; std::vector<CompletionEmote> emotes;
auto *app = getIApp();
auto *tc = dynamic_cast<TwitchChannel *>(channel.get()); auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
// returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.)
if (channel->isTwitchChannel()) if (channel->isTwitchChannel())
{ {
if (auto user = getApp()->accounts->twitch.getCurrent()) if (auto user = app->getAccounts()->twitch.getCurrent())
{ {
// Twitch Emotes available globally // Twitch Emotes available globally
auto emoteData = user->accessEmotes(); auto emoteData = user->accessEmotes();
@ -115,21 +95,21 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
} }
} }
if (auto bttvG = getApp()->twitch->getBttvEmotes().emotes()) if (auto bttvG = app->getTwitch()->getBttvEmotes().emotes())
{ {
addEmotes(emotes, *bttvG, text, "Global BetterTTV"); addEmotes(emotes, *bttvG, text, "Global BetterTTV");
} }
if (auto ffzG = getApp()->twitch->getFfzEmotes().emotes()) if (auto ffzG = app->getTwitch()->getFfzEmotes().emotes())
{ {
addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ"); addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ");
} }
if (auto seventvG = getApp()->twitch->getSeventvEmotes().globalEmotes()) if (auto seventvG = app->getTwitch()->getSeventvEmotes().globalEmotes())
{ {
addEmotes(emotes, *seventvG, text, "Global 7TV"); addEmotes(emotes, *seventvG, text, "Global 7TV");
} }
} }
addEmojis(emotes, getApp()->emotes->emojis.emojis, text); addEmojis(emotes, app->getEmotes()->getEmojis()->getEmojis(), text);
// if there is an exact match, put that emote first // if there is an exact match, put that emote first
for (size_t i = 1; i < emotes.size(); i++) for (size_t i = 1; i < emotes.size(); i++)
@ -147,6 +127,34 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
} }
} }
return emotes;
}
} // namespace chatterino::detail
namespace chatterino {
InputCompletionPopup::InputCompletionPopup(QWidget *parent)
: BasePopup({BasePopup::EnableCustomFrame, BasePopup::Frameless,
BasePopup::DontFocus, BaseWindow::DisableLayoutSave},
parent)
, model_(this)
{
this->initLayout();
QObject::connect(&this->redrawTimer_, &QTimer::timeout, this, [this] {
if (this->isVisible())
{
this->ui_.listView->doItemsLayout();
}
});
this->redrawTimer_.setInterval(33);
}
void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
{
auto emotes = detail::buildCompletionEmoteList(text, std::move(channel));
this->model_.clear(); this->model_.clear();
int count = 0; int count = 0;

View file

@ -5,12 +5,29 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <vector>
namespace chatterino { namespace chatterino {
class Channel; class Channel;
using ChannelPtr = std::shared_ptr<Channel>; using ChannelPtr = std::shared_ptr<Channel>;
struct Emote;
using EmotePtr = std::shared_ptr<const Emote>;
namespace detail {
struct CompletionEmote {
EmotePtr emote;
QString displayName;
QString providerName;
};
std::vector<CompletionEmote> buildCompletionEmoteList(const QString &text,
ChannelPtr channel);
} // namespace detail
class GenericListView; class GenericListView;
class InputCompletionPopup : public BasePopup class InputCompletionPopup : public BasePopup

View file

@ -26,6 +26,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/Updates.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Updates.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Filters.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Filters.cpp
${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp
${CMAKE_CURRENT_LIST_DIR}/src/InputCompletion.cpp
# Add your new file above this line! # Add your new file above this line!
) )

View file

@ -1,6 +1,5 @@
#include "controllers/highlights/HighlightController.hpp" #include "controllers/highlights/HighlightController.hpp"
#include "Application.hpp"
#include "BaseSettings.hpp" #include "BaseSettings.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "controllers/highlights/HighlightPhrase.hpp" #include "controllers/highlights/HighlightPhrase.hpp"

View file

@ -0,0 +1,336 @@
#include "Application.hpp"
#include "BaseSettings.hpp"
#include "common/Aliases.hpp"
#include "common/CompletionModel.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "messages/Emote.hpp"
#include "mocks/EmptyApplication.hpp"
#include "mocks/Helix.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Paths.hpp"
#include "singletons/Settings.hpp"
#include "widgets/splits/InputCompletionPopup.hpp"
#include <boost/optional/optional_io.hpp>
#include <gtest/gtest.h>
#include <QDir>
#include <QFile>
#include <QModelIndex>
#include <QString>
namespace {
using namespace chatterino;
using ::testing::Exactly;
class MockTwitchIrcServer : public ITwitchIrcServer
{
public:
const BttvEmotes &getBttvEmotes() const override
{
return this->bttv;
}
const FfzEmotes &getFfzEmotes() const override
{
return this->ffz;
}
const SeventvEmotes &getSeventvEmotes() const override
{
return this->seventv;
}
BttvEmotes bttv;
FfzEmotes ffz;
SeventvEmotes seventv;
};
class MockApplication : mock::EmptyApplication
{
public:
AccountController *getAccounts() override
{
return &this->accounts;
}
ITwitchIrcServer *getTwitch() override
{
return &this->twitch;
}
IEmotes *getEmotes() override
{
return &this->emotes;
}
AccountController accounts;
MockTwitchIrcServer twitch;
Emotes emotes;
};
} // namespace
namespace chatterino {
class MockChannel : public Channel
{
public:
MockChannel(const QString &name)
: Channel(name, Channel::Type::Twitch)
{
}
};
} // namespace chatterino
EmotePtr namedEmote(const EmoteName &name)
{
return std::shared_ptr<Emote>(new Emote{
.name{name},
.images{},
.tooltip{},
.zeroWidth{},
.id{},
.author{},
});
}
void addEmote(EmoteMap &map, const QString &name)
{
EmoteName eName{.string{name}};
map.insert(std::pair<EmoteName, EmotePtr>(eName, namedEmote(eName)));
}
static QString DEFAULT_SETTINGS = R"!(
{
"accounts": {
"uid117166826": {
"username": "testaccount_420",
"userID": "117166826",
"clientID": "abc",
"oauthToken": "def"
},
"current": "testaccount_420"
}
})!";
class InputCompletionTest : public ::testing::Test
{
protected:
void SetUp() override
{
// Write default settings to the mock settings json file
ASSERT_TRUE(QDir().mkpath("/tmp/c2-tests"));
QFile settingsFile("/tmp/c2-tests/settings.json");
ASSERT_TRUE(settingsFile.open(QIODevice::WriteOnly | QIODevice::Text));
ASSERT_GT(settingsFile.write(DEFAULT_SETTINGS.toUtf8()), 0);
ASSERT_TRUE(settingsFile.flush());
settingsFile.close();
// Initialize helix client
this->mockHelix = std::make_unique<mock::Helix>();
initializeHelix(this->mockHelix.get());
EXPECT_CALL(*this->mockHelix, loadBlocks).Times(Exactly(1));
EXPECT_CALL(*this->mockHelix, update).Times(Exactly(1));
this->mockApplication = std::make_unique<MockApplication>();
this->settings = std::make_unique<Settings>("/tmp/c2-tests");
this->paths = std::make_unique<Paths>();
this->mockApplication->accounts.initialize(*this->settings,
*this->paths);
this->mockApplication->emotes.initialize(*this->settings, *this->paths);
this->channelPtr = std::make_shared<MockChannel>("icelys");
this->completionModel =
std::make_unique<CompletionModel>(*this->channelPtr);
this->initializeEmotes();
}
void TearDown() override
{
ASSERT_TRUE(QDir("/tmp/c2-tests").removeRecursively());
this->mockApplication.reset();
this->settings.reset();
this->paths.reset();
this->mockHelix.reset();
this->completionModel.reset();
this->channelPtr.reset();
}
std::unique_ptr<MockApplication> mockApplication;
std::unique_ptr<Settings> settings;
std::unique_ptr<Paths> paths;
std::unique_ptr<mock::Helix> mockHelix;
ChannelPtr channelPtr;
std::unique_ptr<CompletionModel> completionModel;
private:
void initializeEmotes()
{
auto bttvEmotes = std::make_shared<EmoteMap>();
addEmote(*bttvEmotes, "FeelsGoodMan");
addEmote(*bttvEmotes, "FeelsBadMan");
addEmote(*bttvEmotes, "FeelsBirthdayMan");
addEmote(*bttvEmotes, "Aware");
addEmote(*bttvEmotes, "Clueless");
addEmote(*bttvEmotes, "SaltyCorn");
addEmote(*bttvEmotes, ":)");
addEmote(*bttvEmotes, ":-)");
addEmote(*bttvEmotes, "B-)");
addEmote(*bttvEmotes, "Clap");
this->mockApplication->twitch.bttv.setEmotes(std::move(bttvEmotes));
auto ffzEmotes = std::make_shared<EmoteMap>();
addEmote(*ffzEmotes, "LilZ");
addEmote(*ffzEmotes, "ManChicken");
addEmote(*ffzEmotes, "CatBag");
this->mockApplication->twitch.ffz.setEmotes(std::move(ffzEmotes));
auto seventvEmotes = std::make_shared<EmoteMap>();
addEmote(*seventvEmotes, "Clap");
addEmote(*seventvEmotes, "Clap2");
this->mockApplication->twitch.seventv.setGlobalEmotes(
std::move(seventvEmotes));
}
protected:
auto queryEmoteCompletion(const QString &fullQuery)
{
// At the moment, buildCompletionEmoteList does not want the ':'.
QString normalizedQuery = fullQuery;
if (normalizedQuery.startsWith(':'))
{
normalizedQuery = normalizedQuery.mid(1);
}
return chatterino::detail::buildCompletionEmoteList(normalizedQuery,
this->channelPtr);
}
auto queryTabCompletion(const QString &fullQuery, bool isFirstWord)
{
this->completionModel->refresh(fullQuery, isFirstWord);
return this->completionModel->allItems();
}
};
TEST_F(InputCompletionTest, EmoteNameFiltering)
{
auto completion = queryEmoteCompletion(":feels");
ASSERT_EQ(completion.size(), 3);
ASSERT_EQ(completion[0].displayName, "FeelsBirthdayMan");
ASSERT_EQ(completion[1].displayName, "FeelsBadMan");
ASSERT_EQ(completion[2].displayName, "FeelsGoodMan");
completion = queryEmoteCompletion(":)");
ASSERT_EQ(completion.size(), 3);
ASSERT_EQ(completion[0].displayName, ":)"); // Exact match with : prefix
ASSERT_EQ(completion[1].displayName, ":-)");
ASSERT_EQ(completion[2].displayName, "B-)");
completion = queryEmoteCompletion(":cat");
ASSERT_TRUE(completion.size() >= 2);
// emoji exact match comes first
ASSERT_EQ(completion[0].displayName, "cat");
// FFZ emote is prioritized over any other matching emojis
ASSERT_EQ(completion[1].displayName, "CatBag");
}
TEST_F(InputCompletionTest, EmoteExactNameMatching)
{
auto completion = queryEmoteCompletion(":cat");
ASSERT_TRUE(completion.size() >= 2);
// emoji exact match comes first
ASSERT_EQ(completion[0].displayName, "cat");
// FFZ emote is prioritized over any other matching emojis
ASSERT_EQ(completion[1].displayName, "CatBag");
// not exactly "salt", SaltyCorn BTTV emote comes first
completion = queryEmoteCompletion(":sal");
ASSERT_TRUE(completion.size() >= 3);
ASSERT_EQ(completion[0].displayName, "SaltyCorn");
ASSERT_EQ(completion[1].displayName, "green_salad");
ASSERT_EQ(completion[2].displayName, "salt");
// exactly "salt", emoji comes first
completion = queryEmoteCompletion(":salt");
ASSERT_TRUE(completion.size() >= 2);
ASSERT_EQ(completion[0].displayName, "salt");
ASSERT_EQ(completion[1].displayName, "SaltyCorn");
}
TEST_F(InputCompletionTest, EmoteProviderOrdering)
{
auto completion = queryEmoteCompletion(":clap");
// Current implementation leads to the exact first match being ignored when
// checking for exact matches. This is probably not intended behavior but
// this test is just verifying that the implementation stays the same.
//
// Initial ordering after filtering all available emotes:
// 1. Clap - BTTV
// 2. Clap - 7TV
// 3. Clap2 - 7TV
// 4. clapper - Emoji
// 5. clap - Emoji
//
// The 'exact match' starts looking at the second element and ends up swapping
// #2 with #1 despite #1 already being an exact match.
ASSERT_TRUE(completion.size() >= 5);
ASSERT_EQ(completion[0].displayName, "Clap");
ASSERT_EQ(completion[0].providerName, "Global 7TV");
ASSERT_EQ(completion[1].displayName, "Clap");
ASSERT_EQ(completion[1].providerName, "Global BetterTTV");
ASSERT_EQ(completion[2].displayName, "Clap2");
ASSERT_EQ(completion[2].providerName, "Global 7TV");
ASSERT_EQ(completion[3].displayName, "clapper");
ASSERT_EQ(completion[3].providerName, "Emoji");
ASSERT_EQ(completion[4].displayName, "clap");
ASSERT_EQ(completion[4].providerName, "Emoji");
}
TEST_F(InputCompletionTest, TabCompletionEmote)
{
auto completion = queryTabCompletion(":feels", false);
ASSERT_EQ(completion.size(), 0); // : prefix matters here
// no : prefix defaults to emote completion
completion = queryTabCompletion("feels", false);
ASSERT_EQ(completion.size(), 3);
// note: different order from : menu
ASSERT_EQ(completion[0], "FeelsBadMan ");
ASSERT_EQ(completion[1], "FeelsBirthdayMan ");
ASSERT_EQ(completion[2], "FeelsGoodMan ");
// no : prefix, emote completion. Duplicate Clap should be removed
completion = queryTabCompletion("cla", false);
ASSERT_EQ(completion.size(), 2);
ASSERT_EQ(completion[0], "Clap ");
ASSERT_EQ(completion[1], "Clap2 ");
completion = queryTabCompletion("peepoHappy", false);
ASSERT_EQ(completion.size(), 0); // no peepoHappy emote
completion = queryTabCompletion("Aware", false);
ASSERT_EQ(completion.size(), 1);
ASSERT_EQ(completion[0], "Aware "); // trailing space added
}
TEST_F(InputCompletionTest, TabCompletionEmoji)
{
auto completion = queryTabCompletion(":cla", false);
ASSERT_EQ(completion.size(), 8);
ASSERT_EQ(completion[0], ":clap: ");
ASSERT_EQ(completion[1], ":clap_tone1: ");
ASSERT_EQ(completion[2], ":clap_tone2: ");
ASSERT_EQ(completion[3], ":clap_tone3: ");
ASSERT_EQ(completion[4], ":clap_tone4: ");
ASSERT_EQ(completion[5], ":clap_tone5: ");
ASSERT_EQ(completion[6], ":clapper: ");
ASSERT_EQ(completion[7], ":classical_building: ");
}

View file

@ -1,6 +1,5 @@
#include "providers/twitch/TwitchMessageBuilder.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp"
#include "Application.hpp"
#include "common/Channel.hpp" #include "common/Channel.hpp"
#include "messages/MessageBuilder.hpp" #include "messages/MessageBuilder.hpp"
#include "mocks/EmptyApplication.hpp" #include "mocks/EmptyApplication.hpp"
@ -27,6 +26,7 @@ public:
{ {
return &this->emotes; return &this->emotes;
} }
IUserDataController *getUserData() override IUserDataController *getUserData() override
{ {
return &this->userData; return &this->userData;