test: add snapshot tests for MessageBuilder (#5598)

This commit is contained in:
nerix
2024-10-13 12:38:10 +02:00
committed by GitHub
parent 3e2116629a
commit d9453313b3
90 changed files with 14661 additions and 218 deletions
+506 -70
View File
@@ -1,34 +1,63 @@
#include "messages/MessageBuilder.hpp"
#include "common/Literals.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/highlights/HighlightController.hpp"
#include "controllers/ignores/IgnorePhrase.hpp"
#include "controllers/sound/NullBackend.hpp"
#include "lib/Snapshot.hpp"
#include "messages/Emote.hpp"
#include "messages/Message.hpp"
#include "mocks/BaseApplication.hpp"
#include "mocks/Channel.hpp"
#include "mocks/ChatterinoBadges.hpp"
#include "mocks/DisabledStreamerMode.hpp"
#include "mocks/Emotes.hpp"
#include "mocks/LinkResolver.hpp"
#include "mocks/Logging.hpp"
#include "mocks/TwitchIrcServer.hpp"
#include "mocks/UserData.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/seventv/SeventvBadges.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchBadge.hpp"
#include "providers/twitch/TwitchBadges.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Emotes.hpp"
#include "Test.hpp"
#include "util/IrcHelpers.hpp"
#include <IrcConnection>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QString>
#include <unordered_map>
#include <vector>
using namespace chatterino;
using chatterino::mock::MockChannel;
using namespace literals;
namespace {
/// Controls whether snapshots will be updated (true) or verified (false)
///
/// In CI, all snapshots must be verified, thus the integrity tests checks for
/// this constant.
///
/// When adding a test, start with `{ "input": "..." }` and set this to `true`
/// to generate an initial snapshot. Make sure to verify the output!
constexpr bool UPDATE_SNAPSHOTS = false;
const QString IRC_CATEGORY = u"MessageBuilder/IRC"_s;
class MockApplication : public mock::BaseApplication
{
public:
@@ -37,6 +66,12 @@ public:
{
}
MockApplication(const QString &settingsData)
: mock::BaseApplication(settingsData)
, highlights(this->settings, &this->accounts)
{
}
IEmotes *getEmotes() override
{
return &this->emotes;
@@ -97,6 +132,21 @@ public:
return &this->logging;
}
TwitchBadges *getTwitchBadges() override
{
return &this->twitchBadges;
}
ILinkResolver *getLinkResolver() override
{
return &this->linkResolver;
}
ISoundController *getSound() override
{
return &this->sound;
}
mock::EmptyLogging logging;
AccountController accounts;
mock::Emotes emotes;
@@ -109,8 +159,292 @@ public:
BttvEmotes bttvEmotes;
FfzEmotes ffzEmotes;
SeventvEmotes seventvEmotes;
TwitchBadges twitchBadges;
mock::EmptyLinkResolver linkResolver;
NullBackend sound;
};
std::pair<const EmoteName, EmotePtr> makeEmote(Emote &&emote)
{
auto ptr = std::make_shared<Emote>(std::move(emote));
ptr->homePage = {u"https://chatterino.com/" % ptr->name.string};
ptr->tooltip = {ptr->name.string % u" Tooltip"_s};
ptr->author = {u"Chatterino"_s};
ptr->images = {
Url{u"https://chatterino.com/" % ptr->name.string % u".png"}};
return {ptr->name, 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;
}
QT_WARNING_PUSH
QT_WARNING_DISABLE_CLANG("-Wmissing-field-initializers")
struct MockEmotes {
EmoteMapPtr seventv;
EmoteMapPtr bttv;
EmoteMapPtr ffz;
EmoteMapPtr twitchAccount;
static MockEmotes channel()
{
return {
.seventv = makeEmotes(
Emote{
.name = {u"7TVEmote"_s},
.id = {u"1"_s},
},
Emote{
.name = {u"7TVEmote0w"_s},
.zeroWidth = true,
.id = {u"2"_s},
.baseName = EmoteName{u"ZeroWidth"_s},
},
Emote{
.name = {u"PogChamp"_s},
.id = {u"3"_s},
}),
.bttv = makeEmotes(
Emote{
.name = {u"BTTVEmote"_s},
},
Emote{
.name = {u"Kappa"_s},
}),
.ffz = makeEmotes(
Emote{
.name = {u"FFZEmote"_s},
},
Emote{
.name = {u"Keepo"_s},
}),
};
}
static MockEmotes twitchdev()
{
return {
.seventv = makeEmotes(Emote{
.name = {u"7TVTwitchDev"_s},
.id = {u"t5"_s},
}),
.bttv = makeEmotes(Emote{
.name = {u"BTTVTwitchDev"_s},
}),
.ffz = makeEmotes(Emote{
.name = {u"FFZTwitchDev"_s},
}),
};
}
static MockEmotes global()
{
return {
.seventv = makeEmotes(Emote{
.name = {u"7TVGlobal"_s},
.id = {u"G1"_s},
}),
.bttv = makeEmotes(Emote{
.name = {u"BTTVGlobal"_s},
}),
.ffz = makeEmotes(Emote{
.name = {u"FFZGlobal"_s},
}),
.twitchAccount = makeEmotes(Emote{
.name = {u"MyCoolTwitchEmote"_s},
.id = {u"5678"_s},
}),
};
}
};
const QByteArray CHEERMOTE_JSON{R"({
"prefix": "Cheer",
"tiers": [
{
"min_bits": 1,
"id": "1",
"color": "#979797",
"images": {
"dark": {
"animated": {
"1": "https://chatterino.com/bits/1.gif",
"2": "https://chatterino.com/bits/2.gif",
"4": "https://chatterino.com/bits/4.gif"
},
"static": {
"1": "https://chatterino.com/bits/1.png",
"2": "https://chatterino.com/bits/2.png",
"4": "https://chatterino.com/bits/4.png"
}
}
},
"can_cheer": true,
"show_in_bits_card": true
},
{
"min_bits": 100,
"id": "100",
"color": "#9c3ee8",
"images": {
"dark": {
"animated": {
"1": "https://chatterino.com/bits/1.gif",
"2": "https://chatterino.com/bits/2.gif",
"4": "https://chatterino.com/bits/4.gif"
},
"static": {
"1": "https://chatterino.com/bits/1.png",
"2": "https://chatterino.com/bits/2.png",
"4": "https://chatterino.com/bits/4.png"
}
}
},
"can_cheer": true,
"show_in_bits_card": true
}
],
"type": "global_first_party",
"order": 1,
"last_updated": "2018-05-22T00:06:04Z",
"is_charitable": false
})"_ba};
const QByteArray LOCAL_BADGE_JSON{R"({
"data": [
{
"set_id": "subscriber",
"versions": [
{
"click_url": null,
"description": "Subscriber",
"id": "3072",
"image_url_1x": "https://chatterino.com/tb-1",
"image_url_2x": "https://chatterino.com/tb-2",
"image_url_4x": "https://chatterino.com/tb-3",
"title": "Subscriber"
}
]
}
]
})"_ba};
const QByteArray SETTINGS_DEFAULT{"{}"_ba};
std::shared_ptr<TwitchChannel> makeMockTwitchChannel(
const QString &name, const testlib::Snapshot &snapshot)
{
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));
QJsonObject defaultImage{
{u"url_1x"_s, u"https://chatterino.com/reward1x.png"_s},
{u"url_2x"_s, u"https://chatterino.com/reward2x.png"_s},
{u"url_4x"_s, u"https://chatterino.com/reward4x.png"_s},
};
chan->addKnownChannelPointReward({{
{u"channel_id"_s, u"11148817"_s},
{u"id"_s, u"unused"_s},
{u"reward"_s,
{{
{u"channel_id"_s, u"11148817"_s},
{u"cost"_s, 1},
{u"id"_s, u"31a2344e-0fce-4229-9453-fb2e8b6dd02c"_s},
{u"is_user_input_required"_s, true},
{u"title"_s, u"my reward"_s},
{u"image"_s, defaultImage},
}}},
}});
chan->addKnownChannelPointReward({{
{u"channel_id"_s, u"11148817"_s},
{u"id"_s, u"unused"_s},
{u"reward"_s,
{{
{u"channel_id"_s, u"11148817"_s},
{u"cost"_s, 1},
{u"id"_s, u"dc8d1dac-256e-42b9-b7ba-40b32e5294e2"_s},
{u"is_user_input_required"_s, false},
{u"title"_s, u"test"_s},
{u"image"_s, defaultImage},
}}},
}});
chan->addKnownChannelPointReward({{
{u"channel_id"_s, u"11148817"_s},
{u"id"_s, u"unused"_s},
{u"reward"_s,
{{
{u"channel_id"_s, u"11148817"_s},
{u"cost"_s, 1},
{u"default_bits_cost"_s, 2},
{u"bits_cost"_s, 0},
{u"pricing_type"_s, u"BITS"_s},
{u"reward_type"_s, u"CELEBRATION"_s},
{u"is_user_input_required"_s, false},
{u"title"_s, u"BitReward"_s},
{u"image"_s, defaultImage},
}}},
{u"redemption_metadata"_s,
QJsonObject{
{u"celebration_emote_metadata"_s,
QJsonObject{
{u"emote"_s,
{{
{u"id"_s, u"42"_s},
{u"token"_s, u"MyBitsEmote"_s},
}}},
}},
}},
}});
chan->setUserColor("UserColor", {1, 2, 3, 4});
chan->setUserColor("UserColor2", {5, 6, 7, 8});
chan->addRecentChatter("UserChatter");
chan->addRecentChatter("UserColor");
chan->setCheerEmoteSets({
HelixCheermoteSet{QJsonDocument::fromJson(CHEERMOTE_JSON).object()},
});
chan->setFfzChannelBadges({{u"123456"_s, {3, 4}}});
chan->addTwitchBadgeSets(HelixChannelBadges{
QJsonDocument::fromJson(LOCAL_BADGE_JSON).object(),
});
if (snapshot.param("ffzCustomVipBadge").toBool())
{
chan->setFfzCustomVipBadge(std::make_shared<Emote>(Emote{
.name = {},
.images = {Url{"https://chatterino.com/ffz-vip1x.png"}},
.tooltip = {"VIP"},
.homePage = {},
}));
}
if (snapshot.param("ffzCustomModBadge").toBool())
{
chan->setFfzCustomModBadge(std::make_shared<Emote>(Emote{
.name = {},
.images = {Url{"https://chatterino.com/ffz-mod1x.png"}},
.tooltip = {"Moderator"},
.homePage = {},
}));
}
return chan;
}
QT_WARNING_POP
} // namespace
TEST(MessageBuilder, CommaSeparatedListTagParsing)
@@ -408,7 +742,7 @@ TEST_F(TestMessageBuilder, ParseTwitchEmotes)
for (const auto &test : testCases)
{
auto *privmsg = static_cast<Communi::IrcPrivateMessage *>(
auto *privmsg = dynamic_cast<Communi::IrcPrivateMessage *>(
Communi::IrcPrivateMessage::fromData(test.input, nullptr));
QString originalMessage = privmsg->content();
@@ -423,73 +757,6 @@ TEST_F(TestMessageBuilder, ParseTwitchEmotes)
}
}
TEST_F(TestMessageBuilder, ParseMessage)
{
MockChannel channel("pajlada");
struct TestCase {
QByteArray input;
};
std::vector<TestCase> testCases{
{
// action /me message
R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=90ef1e46-8baa-4bf2-9c54-272f39d6fa11;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662206235860;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :ACTION Kappa)",
},
{
R"(@badge-info=subscriber/17;badges=subscriber/12,no_audio/1;color=#EBA2C0;display-name=jammehcow;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=9c2dd916-5a6d-4c1f-9fe7-a081b62a9c6b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662201093248;turbo=0;user-id=82674227;user-type= :jammehcow!jammehcow@jammehcow.tmi.twitch.tv PRIVMSG #pajlada :Kappa)",
},
{
R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=1902:0-4;first-msg=0;flags=;id=9b1c3cb9-7817-47ea-add1-f9d4a9b4f846;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201095690;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Keepo)",
},
{
R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=25:0-4/1902:6-10/305954156:12-19;first-msg=0;flags=;id=7be87072-bf24-4fa3-b3df-0ea6fa5f1474;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201102276;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Kappa Keepo PogChamp)",
},
{
R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4,6-10;first-msg=0;flags=;id=f7516287-e5d1-43ca-974e-fe0cff84400b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204375009;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa Kappa)",
},
{
R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:0-4,8-12;first-msg=0;flags=;id=44f85d39-b5fb-475d-8555-f4244f2f7e82;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204423418;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa 😂 Kappa)",
},
{
// start out of range
R"(@emotes=84608:9-10 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)",
},
{
// one character emote
R"(@emotes=84608:0-0 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)",
},
{
// two character emote
R"(@emotes=84609:0-1 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)",
},
{
// end out of range
R"(@emotes=84608:0-15 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)",
},
{
// range bad (end character before start)
R"(@emotes=84608:15-2 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)",
},
};
for (const auto &test : testCases)
{
auto *privmsg = dynamic_cast<Communi::IrcPrivateMessage *>(
Communi::IrcPrivateMessage::fromData(test.input, nullptr));
EXPECT_NE(privmsg, nullptr);
QString originalMessage = privmsg->content();
MessageBuilder builder(&channel, privmsg, MessageParseArgs{});
auto msg = builder.build();
EXPECT_NE(msg.get(), nullptr);
delete privmsg;
}
}
TEST_F(TestMessageBuilder, IgnoresReplace)
{
struct TestCase {
@@ -627,3 +894,172 @@ TEST_F(TestMessageBuilder, IgnoresReplace)
<< "' and output '" << message << "'";
}
}
class TestMessageBuilderP : public ::testing::TestWithParam<QString>
{
public:
void SetUp() override
{
auto param = TestMessageBuilderP::GetParam();
this->snapshot = testlib::Snapshot::read(IRC_CATEGORY, param);
this->mockApplication =
std::make_unique<MockApplication>(QString::fromUtf8(
this->snapshot->mergedSettings(SETTINGS_DEFAULT)));
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->mockApplication->getUserData()->setUserColor(u"117691339"_s,
u"#DAA521"_s);
this->mockApplication->getAccounts()
->twitch.getCurrent()
->blockUserLocally(u"12345"_s);
auto makeBadge = [](QStringView platform) {
return std::make_shared<Emote>(Emote{
.name = {},
.images = {Url{u"https://chatterino.com/" % platform %
u".png"}},
.tooltip = {platform % u" badge"},
.homePage = {},
.zeroWidth = false,
.id = {},
.author = {},
.baseName = {},
});
};
// Chatterino
this->mockApplication->chatterinoBadges.setBadge(
{u"123456"_s}, makeBadge(u"Chatterino"));
// FFZ
this->mockApplication->ffzBadges.registerBadge(
1, {.emote = makeBadge(u"FFZ1"), .color = {9, 10, 11, 12}});
this->mockApplication->ffzBadges.registerBadge(
2, {.emote = makeBadge(u"FFZ2"), .color = {13, 14, 15, 16}});
this->mockApplication->ffzBadges.registerBadge(
3, {.emote = makeBadge(u"FFZ2"), .color = {17, 18, 19, 20}});
this->mockApplication->ffzBadges.registerBadge(
4, {.emote = makeBadge(u"FFZ2"), .color = {21, 22, 23, 24}});
this->mockApplication->getFfzBadges()->assignBadgeToUser({u"123456"_s},
1);
this->mockApplication->getFfzBadges()->assignBadgeToUser({u"123456"_s},
2);
// 7TV
this->mockApplication->getSeventvBadges()->registerBadge({
{u"id"_s, u"1"_s},
{u"tooltip"_s, u"7TV badge"_s},
{
u"host"_s,
{{
{u"url"_s, u"//chatterino.com/7tv/"_s},
{u"files"_s,
QJsonArray{
{{
{u"name"_s, u"1x"_s},
{u"format"_s, u"WEBP"_s},
{u"width"_s, 16},
}},
}},
}},
},
});
this->mockApplication->getSeventvBadges()->assignBadgeToUser(
u"1"_s, {u"123456"_s});
// Twitch
this->mockApplication->getTwitchBadges()->loadLocalBadges();
this->twitchdevChannel = std::make_shared<TwitchChannel>("twitchdev");
this->twitchdevChannel->setRoomId("141981764");
auto tdMocks = MockEmotes::twitchdev();
this->twitchdevChannel->setSeventvEmotes(std::move(tdMocks.seventv));
this->twitchdevChannel->setBttvEmotes(std::move(tdMocks.bttv));
this->twitchdevChannel->setFfzEmotes(std::move(tdMocks.ffz));
this->mockApplication->twitch.mockChannels.emplace(
"twitchdev", this->twitchdevChannel);
}
void TearDown() override
{
this->twitchdevChannel.reset();
this->mockApplication.reset();
this->snapshot.reset();
}
std::shared_ptr<TwitchChannel> twitchdevChannel;
std::unique_ptr<MockApplication> mockApplication;
std::unique_ptr<testlib::Snapshot> snapshot;
};
/// This tests the process of parsing IRC messages and emitting `MessagePtr`s.
///
/// Even though it's in the message builder category, this uses
/// `IrcMesssageHandler` to ensure the correct (or: "real") arguments to build
/// messages.
///
/// Tests are contained in `tests/snapshots/MessageBuilder/IRC`. Fixtures
/// consist of an object with the keys `input`, `output`, `settings` (optional),
/// and `params` (optional).
///
/// `UPDATE_SNAPSHOTS` (top) controls whether the `output` will be generated or
/// checked.
///
/// `params` is an optional object with the following keys:
/// - `prevMessages`: An array of past messages (used for replies)
/// - `findAllUsernames`: A boolean controlling the equally named setting
/// (default: false)
TEST_P(TestMessageBuilderP, Run)
{
auto channel = makeMockTwitchChannel(u"pajlada"_s, *snapshot);
std::vector<MessagePtr> prevMessages;
for (auto prevInput : snapshot->param("prevMessages").toArray())
{
auto *ircMessage = Communi::IrcMessage::fromData(
prevInput.toString().toUtf8(), nullptr);
ASSERT_NE(ircMessage, nullptr);
auto builtMessages = IrcMessageHandler::parseMessageWithReply(
channel.get(), ircMessage, prevMessages);
for (const auto &builtMessage : builtMessages)
{
prevMessages.emplace_back(builtMessage);
}
delete ircMessage;
}
auto *ircMessage =
Communi::IrcMessage::fromData(snapshot->inputUtf8(), nullptr);
ASSERT_NE(ircMessage, nullptr);
auto builtMessages = IrcMessageHandler::parseMessageWithReply(
channel.get(), ircMessage, prevMessages);
QJsonArray got;
for (const auto &msg : builtMessages)
{
got.append(msg->toJson());
}
delete ircMessage;
ASSERT_TRUE(snapshot->run(got, UPDATE_SNAPSHOTS));
}
INSTANTIATE_TEST_SUITE_P(
IrcMessage, TestMessageBuilderP,
testing::ValuesIn(testlib::Snapshot::discover(IRC_CATEGORY)));
TEST(TestMessageBuilderP, Integrity)
{
ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested
}
+238
View File
@@ -0,0 +1,238 @@
#include "lib/Snapshot.hpp"
#include "common/Literals.hpp"
#include <QDebug>
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStringBuilder>
namespace {
using namespace chatterino::literals;
bool compareJson(const QJsonValue &expected, const QJsonValue &got,
const QString &context)
{
if (expected == got)
{
return true;
}
if (expected.type() != got.type())
{
qWarning() << context
<< "- mismatching type - expected:" << expected.type()
<< "got:" << got.type();
return false;
}
switch (expected.type())
{
case QJsonValue::Array: {
auto expArr = expected.toArray();
auto gotArr = got.toArray();
if (expArr.size() != gotArr.size())
{
qWarning() << context << "- Mismatching array size - expected:"
<< expArr.size() << "got:" << gotArr.size();
return false;
}
for (QJsonArray::size_type i = 0; i < expArr.size(); i++)
{
if (!compareJson(expArr[i], gotArr[i],
context % '[' % QString::number(i) % ']'))
{
return false;
}
}
}
break; // unreachable
case QJsonValue::Object: {
auto expObj = expected.toObject();
auto gotObj = got.toObject();
if (expObj.size() != gotObj.size())
{
qWarning() << context << "- Mismatching object size - expected:"
<< expObj.size() << "got:" << gotObj.size();
return false;
}
for (auto it = expObj.constBegin(); it != expObj.constEnd(); it++)
{
if (!gotObj.contains(it.key()))
{
qWarning() << context << "- Object doesn't contain key"
<< it.key();
return false;
}
if (!compareJson(it.value(), gotObj[it.key()],
context % '.' % it.key()))
{
return false;
}
}
}
break;
case QJsonValue::Null:
case QJsonValue::Bool:
case QJsonValue::Double:
case QJsonValue::String:
case QJsonValue::Undefined:
break;
}
qWarning() << context << "- expected:" << expected << "got:" << got;
return false;
}
void mergeJson(QJsonObject &base, const QJsonObject &additional)
{
for (auto it = additional.begin(); it != additional.end(); it++)
{
auto ref = base[it.key()];
if (ref.isArray())
{
// there's no way of pushing to the array without detaching first
auto arr = ref.toArray();
if (!it->isArray())
{
throw std::runtime_error("Mismatched types");
}
// append all additional values
auto addArr = it->toArray();
for (auto v : addArr)
{
arr.append(v);
}
ref = arr;
continue;
}
if (ref.isObject())
{
// same here, detach first and overwrite
auto obj = ref.toObject();
if (!it->isObject())
{
throw std::runtime_error("Mismatched types");
}
mergeJson(obj, it->toObject());
ref = obj;
continue;
}
ref = it.value(); // overwrite for simple types/non-existent keys
}
}
QDir baseDir(const QString &category)
{
QDir snapshotDir(QStringLiteral(__FILE__));
snapshotDir.cd("../../../snapshots/");
snapshotDir.cd(category);
return snapshotDir;
}
QString filePath(const QString &category, const QString &name)
{
return baseDir(category).filePath(name);
}
} // namespace
namespace chatterino::testlib {
std::unique_ptr<Snapshot> Snapshot::read(QString category, QString name)
{
if (!name.endsWith(u".json"))
{
name.append(u".json");
}
QFile file(filePath(category, name));
if (!file.open(QFile::ReadOnly))
{
throw std::runtime_error("Failed to open file");
}
auto content = file.readAll();
file.close();
const auto doc = QJsonDocument::fromJson(content).object();
return std::unique_ptr<Snapshot>(
new Snapshot(std::move(category), std::move(name), doc));
}
QStringList Snapshot::discover(const QString &category)
{
auto files =
baseDir(category).entryList(QDir::NoDotAndDotDot | QDir::Files);
for (auto &file : files)
{
file.remove(".json");
}
return files;
}
bool Snapshot::run(const QJsonValue &got, bool updateSnapshots) const
{
if (updateSnapshots)
{
this->write(got);
return true;
}
return compareJson(this->output_, got, QStringLiteral("output"));
}
Snapshot::Snapshot(QString category, QString name, const QJsonObject &root)
: category_(std::move(category))
, name_(std::move(name))
, input_(root["input"_L1])
, params_(root["params"_L1].toObject())
, settings_(root["settings"_L1].toObject())
, output_(root["output"_L1])
{
}
void Snapshot::write(const QJsonValue &got) const
{
QFile file(filePath(this->category_, this->name_));
if (!file.open(QFile::WriteOnly))
{
throw std::runtime_error("Failed to open file");
}
QJsonObject obj{
{"input"_L1, this->input_},
{"output"_L1, got},
};
if (!this->params_.isEmpty())
{
obj.insert("params"_L1, this->params_);
}
if (!this->settings_.isEmpty())
{
obj.insert("settings"_L1, this->settings_);
}
file.write(QJsonDocument{obj}.toJson());
file.close();
}
QByteArray Snapshot::mergedSettings(const QByteArray &base) const
{
auto baseDoc = QJsonDocument::fromJson(base);
if (!baseDoc.isObject())
{
throw std::runtime_error("Invalid base settings");
}
auto baseObj = baseDoc.object();
mergeJson(baseObj, this->settings_);
baseDoc.setObject(baseObj);
return baseDoc.toJson(QJsonDocument::Compact);
}
} // namespace chatterino::testlib
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QString>
#include <memory>
namespace chatterino::testlib {
/// @brief JSON based snapshot/approval tests
///
/// Snapshot tests record the output of some computation based on some @a input.
/// Additionally, users can provide @a params. There isn't any rule on what goes
/// into @a input vs. @a params - a rule of thumb is to put everything that's
/// not directly an input to the target function into @a params (like settings).
/// Similarly, settings can be specified in "settings". These can be merged with
/// existing settings (the base) in mergedSettings().
///
/// Snapshots are stored in `tests/snapshots/{category}/{name}.json`.
/// `category` can consist of multiple directories (e.g. `foo/bar`).
///
/// Note that when using CTest, added snapshots are only discovered when
/// reloading the tests.
///
/// @par A minimal example
///
/// ```cpp
/// #include "lib/Snapshot.hpp"
/// #include "Test.hpp"
///
/// #include <array>
/// #include <QStringBuilder>
///
/// namespace testlib = chatterino::testlib;
///
/// constexpr bool UPDATE_SNAPSHOTS = false;
///
/// class ExampleTest : public ::testing::TestWithParam<QString> {};
///
/// TEST_P(ExampleTest, Run) {
/// auto fixture = testlib::Snapshot::read("category", GetParam());
/// auto output = functionToTest(fixture.input()); // or input{String,Utf8}
/// // if snapshots are supposed to be updated, this will write the output
/// ASSERT_TRUE(fixture.run(output, UPDATE_SNAPSHOTS));
/// }
///
/// INSTANTIATE_TEST_SUITE_P(ExampleInstance, ExampleTest,
/// testing::ValuesIn(testlib::Snapshot::discover("category")));
///
/// // verify that all snapshots are included
/// TEST(ExampleTest, Integrity) {
/// ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested
/// }
/// ```
class Snapshot
{
public:
Snapshot(const Snapshot &) = delete;
Snapshot &operator=(const Snapshot &) = delete;
Snapshot(Snapshot &&) = default;
Snapshot &operator=(Snapshot &&) = default;
~Snapshot() = default;
/// Read a snapshot
static std::unique_ptr<Snapshot> read(QString category, QString name);
/// Finds all tests in @a category
static QStringList discover(const QString &category);
/// @brief Runs the snapshot test
///
/// If @a updateSnapshots is `false`, this checks that @a got matches the
/// expected output (#output()).
/// If @a updateSnapshots is `true`, this sets @a got as the expected
/// output of this snapshot.
bool run(const QJsonValue &got, bool updateSnapshots) const;
QString name() const
{
return this->name_;
}
QString category() const
{
return this->category_;
}
QJsonValue input() const
{
return this->input_;
}
QString inputString() const
{
return this->input_.toString();
}
QByteArray inputUtf8() const
{
return this->input_.toString().toUtf8();
}
QJsonValue param(QLatin1String name) const
{
return this->params_[name];
}
QJsonValue param(const char *name) const
{
return this->param(QLatin1String{name});
}
QByteArray mergedSettings(const QByteArray &base) const;
QJsonValue output() const
{
return this->output_;
}
private:
Snapshot(QString category, QString name, const QJsonObject &root);
void write(const QJsonValue &got) const;
QString category_;
QString name_;
QJsonValue input_;
QJsonObject params_;
QJsonObject settings_;
QJsonValue output_;
};
} // namespace chatterino::testlib