From 25bccc90b4f4209a61302c17250dbbe9496070ad Mon Sep 17 00:00:00 2001 From: nerix Date: Mon, 3 Oct 2022 19:42:02 +0200 Subject: [PATCH] Migrate Remaining Chat Settings Commands to Helix API (#4040) --- CHANGELOG.md | 6 + .../commands/CommandController.cpp | 364 ++++++++++++++++-- src/providers/twitch/api/Helix.cpp | 12 +- src/providers/twitch/api/Helix.hpp | 2 + src/util/Helpers.cpp | 176 +++++++++ src/util/Helpers.hpp | 74 ++++ tests/src/Helpers.cpp | 251 ++++++++++++ 7 files changed, 846 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d279ed..bfd7b6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,12 @@ - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) +- Minor: Migrated /subscribers to Helix API. (#4040) +- Minor: Migrated /subscribersoff to Helix API. (#4040) +- Minor: Migrated /slow to Helix API. (#4040) +- Minor: Migrated /slowoff to Helix API. (#4040) +- Minor: Migrated /followers to Helix API. (#4040) +- Minor: Migrated /followersoff to Helix API. (#4040) - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 41bbdaa1..a3f23edf 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2108,46 +2108,74 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // /raid - const auto formatChatSettingsError = - [](const HelixUpdateChatSettingsError error, const QString &message) { - QString errorMessage = QString("Failed to update - "); - using Error = HelixUpdateChatSettingsError; - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + const auto formatChatSettingsError = [](const HelixUpdateChatSettingsError + error, + const QString &message, + int durationUnitMultiplier = 1) { + static const QRegularExpression invalidRange("(\\d+) through (\\d+)"); - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; + QString errorMessage = QString("Failed to update - "); + using Error = HelixUpdateChatSettingsError; + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; } - return errorMessage; - }; + break; + + case Error::UserNotAuthorized: + case Error::Forbidden: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::OutOfRange: { + QRegularExpressionMatch matched = invalidRange.match(message); + if (matched.hasMatch()) + { + auto from = matched.captured(1).toInt(); + auto to = matched.captured(2).toInt(); + errorMessage += + QString("The duration is out of the valid range: " + "%1 through %2.") + .arg(from == 0 ? "0s" + : formatTime(from * + durationUnitMultiplier), + to == 0 + ? "0s" + : formatTime(to * durationUnitMultiplier)); + } + else + { + errorMessage += message; + } + } + break; + + case Error::Forwarded: { + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; this->registerCommand("/emoteonly", [formatChatSettingsError]( const QStringList & /* words */, @@ -2223,6 +2251,266 @@ void CommandController::initialize(Settings &, Paths &paths) }); return ""; }); + + this->registerCommand( + "/subscribers", [formatChatSettingsError]( + const QStringList & /* words */, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /subscribers command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->submode) + { + channel->addMessage(makeSystemMessage( + "This room is already in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + twitchChannel->roomId(), currentUser->getUserId(), true, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/subscribersoff", [formatChatSettingsError]( + const QStringList + & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /subscribersoff command only works in Twitch channels")); + return ""; + } + + if (!twitchChannel->accessRoomModes()->submode) + { + channel->addMessage(makeSystemMessage( + "This room is not in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + twitchChannel->roomId(), currentUser->getUserId(), false, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/slow", [formatChatSettingsError]( + const QStringList &words, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /slow command only works in Twitch channels")); + return ""; + } + + int duration = 30; + if (words.length() >= 2) + { + bool ok = false; + duration = words.at(1).toInt(&ok); + if (!ok || duration <= 0) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/slow [duration]\" - Enables slow mode (limit " + "how often users may send messages). Duration (optional, " + "default=30) must be a positive number of seconds. Use " + "\"slowoff\" to disable. ")); + return ""; + } + } + + if (twitchChannel->accessRoomModes()->slowMode == duration) + { + channel->addMessage(makeSystemMessage( + QString("This room is already in %1-second slow mode.") + .arg(duration))); + return ""; + } + + getHelix()->updateSlowMode( + twitchChannel->roomId(), currentUser->getUserId(), duration, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand( + "/slowoff", [formatChatSettingsError](const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /slowoff command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->slowMode <= 0) + { + channel->addMessage( + makeSystemMessage("This room is not in slow mode.")); + return ""; + } + + getHelix()->updateSlowMode( + twitchChannel->roomId(), currentUser->getUserId(), boost::none, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/followers", [formatChatSettingsError]( + const QStringList &words, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /followers command only works in Twitch channels")); + return ""; + } + + int duration = 0; + if (words.length() >= 2) + { + auto parsed = parseDurationToSeconds(words.mid(1).join(' '), 60); + duration = (int)(parsed / 60); + // -1 / 60 == 0 => use parsed + if (parsed < 0) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/followers [duration]\" - Enables followers-only" + " mode (only users who have followed for 'duration' may " + "chat). Examples: \"30m\", \"1 week\", \"5 days 12 " + "hours\". Must be less than 3 months. ")); + return ""; + } + } + + if (twitchChannel->accessRoomModes()->followerOnly == duration) + { + channel->addMessage(makeSystemMessage( + QString("This room is already in %1 followers-only mode.") + .arg(formatTime(duration * 60)))); + return ""; + } + + getHelix()->updateFollowerMode( + twitchChannel->roomId(), currentUser->getUserId(), duration, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message, 60))); + }); + return ""; + }); + + this->registerCommand("/followersoff", [formatChatSettingsError]( + const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /followersoff command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->followerOnly < 0) + { + channel->addMessage( + makeSystemMessage("This room is not in followers-only mode. ")); + return ""; + } + + getHelix()->updateFollowerMode( + twitchChannel->roomId(), currentUser->getUserId(), boost::none, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 53225c0b..44fb8d84 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1650,7 +1650,17 @@ void Helix::updateChatSettings( switch (result.status()) { - case 400: + case 400: { + if (message.contains("must be in the range")) + { + failureCallback(Error::OutOfRange, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; case 409: case 422: case 425: { diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 9f967080..83995377 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -460,6 +460,8 @@ enum class HelixUpdateChatSettingsError { // update chat settings UserNotAuthorized, Ratelimited, Forbidden, + OutOfRange, + // The error message is forwarded directly from the Twitch API Forwarded, }; // update chat settings diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index 467b9ecb..7df39196 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -8,6 +8,110 @@ namespace chatterino { +namespace _helpers_internal { + + int skipSpace(const QStringRef &view, int startPos) + { + while (startPos < view.length() && view.at(startPos).isSpace()) + { + startPos++; + } + return startPos - 1; + } + + bool matchesIgnorePlural(const QStringRef &word, const QString &singular) + { + if (!word.startsWith(singular)) + { + return false; + } + if (word.length() == singular.length()) + { + return true; + } + return word.length() == singular.length() + 1 && + word.at(word.length() - 1).toLatin1() == 's'; + } + + std::pair findUnitMultiplierToSec(const QStringRef &view, + int &pos) + { + // Step 1. find end of unit + int startIdx = pos; + int endIdx = view.length(); + for (; pos < view.length(); pos++) + { + auto c = view.at(pos); + if (c.isSpace() || c.isDigit()) + { + endIdx = pos; + break; + } + } + pos--; + + // TODO(QT6): use sliced (more readable) + auto unit = view.mid(startIdx, endIdx - startIdx); + if (unit.isEmpty()) + { + return std::make_pair(0, false); + } + + auto first = unit.at(0).toLatin1(); + switch (first) + { + case 's': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("second"))) + { + return std::make_pair(1, true); + } + } + break; + case 'm': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("minute"))) + { + return std::make_pair(60, true); + } + if ((unit.length() == 2 && unit.at(1).toLatin1() == 'o') || + matchesIgnorePlural(unit, QStringLiteral("month"))) + { + return std::make_pair(60 * 60 * 24 * 30, true); + } + } + break; + case 'h': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("hour"))) + { + return std::make_pair(60 * 60, true); + } + } + break; + case 'd': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("day"))) + { + return std::make_pair(60 * 60 * 24, true); + } + } + break; + case 'w': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("week"))) + { + return std::make_pair(60 * 60 * 24 * 7, true); + } + } + break; + } + return std::make_pair(0, false); + } + +} // namespace _helpers_internal +using namespace _helpers_internal; + bool startsWithOrContains(const QString &str1, const QString &str2, Qt::CaseSensitivity caseSensitivity, bool startsWith) { @@ -93,4 +197,76 @@ QString formatUserMention(const QString &userName, bool isFirstWord, return result; } +int64_t parseDurationToSeconds(const QString &inputString, + uint64_t noUnitMultiplier) +{ + if (inputString.length() == 0) + { + return -1; + } + + // TODO(QT6): use QStringView + QStringRef input(&inputString); + input = input.trimmed(); + + uint64_t currentValue = 0; + + bool visitingNumber = true; // input must start with a number + int numberStartIdx = 0; + + for (int pos = 0; pos < input.length(); pos++) + { + QChar c = input.at(pos); + + if (visitingNumber && !c.isDigit()) + { + uint64_t parsed = + (uint64_t)input.mid(numberStartIdx, pos - numberStartIdx) + .toUInt(); + + if (c.isSpace()) + { + pos = skipSpace(input, pos) + 1; + if (pos >= input.length()) + { + // input like "40 ", this shouldn't happen + // since we trimmed the view + return -1; + } + c = input.at(pos); + } + + auto result = findUnitMultiplierToSec(input, pos); + if (!result.second) + { + return -1; // invalid unit or leading spaces (shouldn't happen) + } + + currentValue += parsed * result.first; + visitingNumber = false; + } + else if (!visitingNumber && !c.isSpace()) + { + if (!c.isDigit()) + { + return -1; // expected a digit + } + visitingNumber = true; + numberStartIdx = pos; + } + // else: visitingNumber && isDigit || !visitingNumber && isSpace + } + + if (visitingNumber) + { + if (numberStartIdx != 0) + { + return -1; // input like "1w 3s 70", 70 what? apples? + } + currentValue += input.toUInt() * noUnitMultiplier; + } + + return (int64_t)currentValue; +} + } // namespace chatterino diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 8e5bd6a9..409089ed 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -2,11 +2,54 @@ #include #include +#include #include namespace chatterino { +// only qualified for tests +namespace _helpers_internal { + + /** + * Skips all spaces. + * The caller must guarantee view.at(startPos).isSpace(). + * + * @param view The string to skip spaces in. + * @param startPos The starting position (there must be a space in the view). + * @return The position of the last space. + */ + int skipSpace(const QStringRef &view, int startPos); + + /** + * Checks if `word` equals `expected` (singular) or `expected` + 's' (plural). + * + * @param word Word to test. Must not be empty. + * @param expected Singular of the expected word. + * @return true if `word` is singular or plural of `expected`. + */ + bool matchesIgnorePlural(const QStringRef &word, const QString &expected); + + /** + * Tries to find the unit starting at `pos` and returns its multiplier so + * `valueInUnit * multiplier = valueInSeconds` (e.g. 60 for minutes). + * + * Supported units are + * 'w[eek(s)]', 'd[ay(s)]', + * 'h[our(s)]', 'm[inute(s)]', 's[econd(s)]'. + * The unit must be in lowercase. + * + * @param view A view into a string + * @param pos The starting position. + * This is set to the last position of the unit + * if it's a valid unit, undefined otherwise. + * @return (multiplier, ok) + */ + std::pair findUnitMultiplierToSec(const QStringRef &view, + int &pos); + +} // namespace _helpers_internal + /** * @brief startsWithOrContains is a wrapper for checking * whether str1 starts with or contains str2 within itself @@ -29,6 +72,37 @@ QString kFormatNumbers(const int &number); QColor getRandomColor(const QString &userId); +/** + * Parses a duration. + * Spaces are allowed before and after a unit but not mandatory. + * Supported units are + * 'w[eek(s)]', 'd[ay(s)]', + * 'h[our(s)]', 'm[inute(s)]', 's[econd(s)]'. + * Units must be lowercase. + * + * If the entire input string is a number (e.g. "12345"), + * then it's multiplied by noUnitMultiplier. + * + * Examples: + * + * - "1w 2h" + * - "1w 1w 0s 4d" (2weeks, 4days) + * - "5s3h4w" (4weeks, 3hours, 5seconds) + * - "30m" + * - "1 week" + * - "5 days 12 hours" + * - "10" (10 * noUnitMultiplier seconds) + * + * @param inputString A non-empty string to parse + * @param noUnitMultiplier A multiplier if the input string only contains one number. + * For example, if a number without a unit should be interpreted + * as a minute, set this to 60. If it should be interpreted + * as a second, set it to 1 (default). + * @return The parsed duration in seconds, -1 if the input is invalid. + */ +int64_t parseDurationToSeconds(const QString &inputString, + uint64_t noUnitMultiplier = 1); + /** * @brief Takes a user's name and some formatting parameter and spits out the standardized way to format it * diff --git a/tests/src/Helpers.cpp b/tests/src/Helpers.cpp index 7db1e1cd..df987f9d 100644 --- a/tests/src/Helpers.cpp +++ b/tests/src/Helpers.cpp @@ -3,6 +3,7 @@ #include using namespace chatterino; +using namespace _helpers_internal; TEST(Helpers, formatUserMention) { @@ -250,3 +251,253 @@ TEST(Helpers, BatchDifferentInputType) EXPECT_EQ(result, expectation); } + +TEST(Helpers, skipSpace) +{ + struct TestCase { + QString input; + int startIdx; + int expected; + }; + + std::vector tests{{"foo bar", 3, 6}, {"foo bar", 3, 3}, + {"foo ", 3, 3}, {"foo ", 3, 6}, + {" ", 0, 2}, {" ", 0, 0}}; + + for (const auto &c : tests) + { + const auto actual = skipSpace(&c.input, c.startIdx); + + EXPECT_EQ(actual, c.expected) + << actual << " (" << qUtf8Printable(c.input) + << ") did not match expected value " << c.expected; + } +} + +TEST(Helpers, findUnitMultiplierToSec) +{ + constexpr uint64_t sec = 1; + constexpr uint64_t min = 60; + constexpr uint64_t hour = min * 60; + constexpr uint64_t day = hour * 24; + constexpr uint64_t week = day * 7; + constexpr uint64_t month = day * 30; + constexpr uint64_t bad = 0; + + struct TestCase { + QString input; + int startPos; + int expectedEndPos; + uint64_t expectedMultiplier; + }; + + std::vector tests{ + {"s", 0, 0, sec}, + {"m", 0, 0, min}, + {"h", 0, 0, hour}, + {"d", 0, 0, day}, + {"w", 0, 0, week}, + {"mo", 0, 1, month}, + + {"s alienpls", 0, 0, sec}, + {"m alienpls", 0, 0, min}, + {"h alienpls", 0, 0, hour}, + {"d alienpls", 0, 0, day}, + {"w alienpls", 0, 0, week}, + {"mo alienpls", 0, 1, month}, + + {"alienpls s", 9, 9, sec}, + {"alienpls m", 9, 9, min}, + {"alienpls h", 9, 9, hour}, + {"alienpls d", 9, 9, day}, + {"alienpls w", 9, 9, week}, + {"alienpls mo", 9, 10, month}, + + {"alienpls s alienpls", 9, 9, sec}, + {"alienpls m alienpls", 9, 9, min}, + {"alienpls h alienpls", 9, 9, hour}, + {"alienpls d alienpls", 9, 9, day}, + {"alienpls w alienpls", 9, 9, week}, + {"alienpls mo alienpls", 9, 10, month}, + + {"second", 0, 5, sec}, + {"minute", 0, 5, min}, + {"hour", 0, 3, hour}, + {"day", 0, 2, day}, + {"week", 0, 3, week}, + {"month", 0, 4, month}, + + {"alienpls2 second", 10, 15, sec}, + {"alienpls2 minute", 10, 15, min}, + {"alienpls2 hour", 10, 13, hour}, + {"alienpls2 day", 10, 12, day}, + {"alienpls2 week", 10, 13, week}, + {"alienpls2 month", 10, 14, month}, + + {"alienpls2 second alienpls", 10, 15, sec}, + {"alienpls2 minute alienpls", 10, 15, min}, + {"alienpls2 hour alienpls", 10, 13, hour}, + {"alienpls2 day alienpls", 10, 12, day}, + {"alienpls2 week alienpls", 10, 13, week}, + {"alienpls2 month alienpls", 10, 14, month}, + + {"seconds", 0, 6, sec}, + {"minutes", 0, 6, min}, + {"hours", 0, 4, hour}, + {"days", 0, 3, day}, + {"weeks", 0, 4, week}, + {"months", 0, 5, month}, + + {"alienpls2 seconds", 10, 16, sec}, + {"alienpls2 minutes", 10, 16, min}, + {"alienpls2 hours", 10, 14, hour}, + {"alienpls2 days", 10, 13, day}, + {"alienpls2 weeks", 10, 14, week}, + {"alienpls2 months", 10, 15, month}, + + {"alienpls2 seconds alienpls", 10, 16, sec}, + {"alienpls2 minutes alienpls", 10, 16, min}, + {"alienpls2 hours alienpls", 10, 14, hour}, + {"alienpls2 days alienpls", 10, 13, day}, + {"alienpls2 weeks alienpls", 10, 14, week}, + {"alienpls2 months alienpls", 10, 15, month}, + + {"sec", 0, 0, bad}, + {"min", 0, 0, bad}, + {"ho", 0, 0, bad}, + {"da", 0, 0, bad}, + {"we", 0, 0, bad}, + {"mon", 0, 0, bad}, + {"foo", 0, 0, bad}, + {"S", 0, 0, bad}, + {"M", 0, 0, bad}, + {"H", 0, 0, bad}, + {"D", 0, 0, bad}, + {"W", 0, 0, bad}, + {"MO", 0, 1, bad}, + + {"alienpls2 sec", 10, 0, bad}, + {"alienpls2 min", 10, 0, bad}, + {"alienpls2 ho", 10, 0, bad}, + {"alienpls2 da", 10, 0, bad}, + {"alienpls2 we", 10, 0, bad}, + {"alienpls2 mon", 10, 0, bad}, + {"alienpls2 foo", 10, 0, bad}, + {"alienpls2 S", 10, 0, bad}, + {"alienpls2 M", 10, 0, bad}, + {"alienpls2 H", 10, 0, bad}, + {"alienpls2 D", 10, 0, bad}, + {"alienpls2 W", 10, 0, bad}, + {"alienpls2 MO", 10, 0, bad}, + + {"alienpls2 sec alienpls", 10, 0, bad}, + {"alienpls2 min alienpls", 10, 0, bad}, + {"alienpls2 ho alienpls", 10, 0, bad}, + {"alienpls2 da alienpls", 10, 0, bad}, + {"alienpls2 we alienpls", 10, 0, bad}, + {"alienpls2 mon alienpls", 10, 0, bad}, + {"alienpls2 foo alienpls", 10, 0, bad}, + {"alienpls2 S alienpls", 10, 0, bad}, + {"alienpls2 M alienpls", 10, 0, bad}, + {"alienpls2 H alienpls", 10, 0, bad}, + {"alienpls2 D alienpls", 10, 0, bad}, + {"alienpls2 W alienpls", 10, 0, bad}, + {"alienpls2 MO alienpls", 10, 0, bad}, + }; + + for (const auto &c : tests) + { + int pos = c.startPos; + const auto actual = findUnitMultiplierToSec(&c.input, pos); + + if (c.expectedMultiplier == bad) + { + EXPECT_FALSE(actual.second) << qUtf8Printable(c.input); + } + else + { + EXPECT_TRUE(pos == c.expectedEndPos && actual.second && + actual.first == c.expectedMultiplier) + << qUtf8Printable(c.input) + << ": Expected(end: " << c.expectedEndPos + << ", mult: " << c.expectedMultiplier << ") Actual(end: " << pos + << ", mult: " << actual.first << ")"; + } + } +} + +TEST(Helpers, parseDurationToSeconds) +{ + struct TestCase { + QString input; + int64_t output; + int64_t noUnitMultiplier = 1; + }; + + auto wrongInput = [](QString &&input) { + return TestCase{input, -1}; + }; + + std::vector tests{ + {"1 minutes 9s", 69}, + {"22 m 17 s", 1337}, + {"7d 5h 10m 52s", 623452}, + {"2h 19m 5s ", 8345}, + {"3d 15 h 13m 54s", 314034}, + {"27s", 27}, + { + "9h 36 m 29s", 34589, + 7, // should be unused + }, + {"1h 59s", 3659}, + {"12d2h22m25s", 1045345}, + {"2h22m25s12d", 1045345}, + {"1d32s", 86432}, + {"0", 0}, + {"0 s", 0}, + {"1weeks", 604800}, + {"2 day5days", 604800}, + {"1 day", 86400}, + {"4 hours 30m 19h 30 minute", 86400}, + {"3 months", 7776000}, + {"1 mo 2month", 7776000}, + // from documentation + {"1w 2h", 612000}, + {"1w 1w 0s 4d", 1555200}, + {"5s3h4w", 2430005}, + // from twitch response + {"30m", 1800}, + {"1 week", 604800}, + {"5 days 12 hours", 475200}, + // noUnitMultiplier + {"0", 0, 60}, + { + "60", 3600, + 60, // minute + }, + { + "1", + 86400, // 1d + 86400, + }, + // wrong input + wrongInput("1min"), + wrongInput(""), + wrongInput("1m5w+5"), + wrongInput("1h30"), + wrongInput("12 34w"), + wrongInput("4W"), + wrongInput("1min"), + wrongInput("4Min"), + wrongInput("4sec"), + }; + + for (const auto &c : tests) + { + const auto actual = parseDurationToSeconds(c.input, c.noUnitMultiplier); + + EXPECT_EQ(actual, c.output) + << actual << " (" << qUtf8Printable(c.input) + << ") did not match expected value " << c.output; + } +}