diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ Quick Qml Widgets + Test ) find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS diff --git a/runners/converter/CMakeLists.txt b/runners/converter/CMakeLists.txt --- a/runners/converter/CMakeLists.txt +++ b/runners/converter/CMakeLists.txt @@ -1,11 +1,22 @@ add_definitions(-DTRANSLATION_DOMAIN=\"plasma_runner_converterrunner\") -set(krunner_converter_SRCS - converterrunner.cpp -) +set(krunner_converter_SRCS converterrunner.cpp) add_library(krunner_converter MODULE ${krunner_converter_SRCS}) -target_link_libraries(krunner_converter KF5::UnitConversion KF5::KIOCore KF5::I18n KF5::Runner) +target_link_libraries(krunner_converter KF5::UnitConversion KF5::I18n KF5::Runner) + install(TARGETS krunner_converter DESTINATION ${KDE_INSTALL_PLUGINDIR}) install(FILES plasma-runner-converter.desktop DESTINATION ${KDE_INSTALL_KSERVICES5DIR}) + +add_library(krunner_converter_test STATIC ${krunner_converter_SRCS}) +target_link_libraries(krunner_converter_test + KF5::I18n + KF5::Runner + KF5::UnitConversion + Qt5::Test + ) + +if(BUILD_TESTING) + add_subdirectory(autotests) +endif() diff --git a/runners/converter/autotests/CMakeLists.txt b/runners/converter/autotests/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/runners/converter/autotests/CMakeLists.txt @@ -0,0 +1,5 @@ +remove_definitions(-DQT_NO_CAST_FROM_ASCII) + +include(ECMAddTests) + +ecm_add_test(converterrunnertest.cpp TEST_NAME converterrunnertest LINK_LIBRARIES Qt5::Test krunner_converter_test) diff --git a/runners/converter/autotests/converterrunnertest.cpp b/runners/converter/autotests/converterrunnertest.cpp new file mode 100644 --- /dev/null +++ b/runners/converter/autotests/converterrunnertest.cpp @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2020 Alexander Lohnau + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include + +#include "../converterrunner.h" + +using namespace KUnitConversion; + +class ConverterRunnerTest : public QObject +{ +Q_OBJECT +private Q_SLOTS: + void initTestCase(); + + void testMostCommonUnits(); + void testSpecificTargetUnit(); + void testCurrency(); + void testLettersAndCurrency(); + void testInvalidCurrency(); + void testFractions(); + void testInvalidFractions(); + +private: + ConverterRunner *runner = nullptr; +}; + +void ConverterRunnerTest::initTestCase() +{ + runner = new ConverterRunner(this, QVariantList()); + runner->init(); +} + +/** + * Test if the most common units are displayed + */ +void ConverterRunnerTest::testMostCommonUnits() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("1m")); + runner->match(context); + + Converter converter; + const auto lengthCategory = converter.category(KUnitConversion::LengthCategory); + QCOMPARE(context.matches().count(), lengthCategory.mostCommonUnits().count() -1); +} + +/* + * Test if specifying a target unit works + */ +void ConverterRunnerTest::testSpecificTargetUnit() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("1m > cm")); + runner->match(context); + + QCOMPARE(context.matches().count(), 1); + QCOMPARE(context.matches().first().text(), QStringLiteral("100 centimeters (cm)")); +} + +/** + * Test of a currency gets converted to the most common currencies + */ +void ConverterRunnerTest::testCurrency() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("1$")); + runner->match(context); + + Converter converter; + const auto currencyCategory = converter.category(KUnitConversion::CurrencyCategory); + QList currencyUnits = currencyCategory.mostCommonUnits(); + + const QString currencyIsoCode = QLocale().currencySymbol(QLocale::CurrencyIsoCode); + const KUnitConversion::Unit localCurrency = currencyCategory.unit(currencyIsoCode); + if (localCurrency.isValid() && !currencyUnits.contains(localCurrency)) { + currencyUnits << localCurrency; + } + QCOMPARE(context.matches().count(), currencyUnits.count() - 1); + +} + +/** + * Test a combination of currency symbols and letters that is not directly supported by the conversion backend + */ +void ConverterRunnerTest::testLettersAndCurrency() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("4us$>ca$")); + runner->match(context); + + QCOMPARE(context.matches().count(), 1); + QVERIFY(context.matches().first().text().contains(QLatin1String("Canadian dollars (CAD)"))); +} + +/** + * Test a query that matches the regex but is not valid + */ +void ConverterRunnerTest::testInvalidCurrency() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("4us$>abc$")); + runner->match(context); + + QCOMPARE(context.matches().count(), 0); +} + +/** + * Test if the factions are correctly parsed + */ +void ConverterRunnerTest::testFractions() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("6/3m>cm")); + runner->match(context); + + QCOMPARE(context.matches().count(), 1); + QCOMPARE(context.matches().first().text(), QStringLiteral("200 centimeters (cm)")); +} + +/** + * Test if an invalid query with a fraction gets rejected + */ +void ConverterRunnerTest::testInvalidFractions() +{ + Plasma::RunnerContext context; + context.setQuery(QStringLiteral("4/4>cm")); + runner->match(context); + + QCOMPARE(context.matches().count(), 0); +} + +QTEST_MAIN(ConverterRunnerTest) + +#include "converterrunnertest.moc" diff --git a/runners/converter/converterrunner.h b/runners/converter/converterrunner.h --- a/runners/converter/converterrunner.h +++ b/runners/converter/converterrunner.h @@ -1,5 +1,6 @@ /* * Copyright (C) 2007,2008 Petri Damstén + * Copyright (C) 2020 Alexander Lohnau * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -18,24 +19,45 @@ #ifndef CONVERTERRUNNER_H #define CONVERTERRUNNER_H -#include +#include +#include +#include +#include +#include + /** * This class converts values to different units. */ -class ConverterRunner : public Plasma::AbstractRunner +class ConverterRunner: public Plasma::AbstractRunner { Q_OBJECT public: - ConverterRunner(QObject* parent, const QVariantList &args); + ConverterRunner(QObject *parent, const QVariantList &args); + void init() override; ~ConverterRunner() override; void match(Plasma::RunnerContext &context) override; void run(const Plasma::RunnerContext &context, const Plasma::QueryMatch &match) override; private: QStringList m_separators; + KUnitConversion::Converter converter; + const QLocale locale; + /** Keys for named regex */ + const QString valueKey = QStringLiteral("queryValue"); + const QString sourceUnitKey = QStringLiteral("sourceUnit"); + const QString targetUnitKey = QStringLiteral("targetUnit"); + QRegularExpression matchRegex; + QRegularExpression hasCurrencyRegex; + /** To convert currency symbols back to ISO string */ + QMap symbolToISOMap; + + QPair stringToDouble(const QStringRef &value); + QPair getValidatedNumberValue(const QString &value); + QList createResultUnits(const QString &outputUnitString, + const KUnitConversion::UnitCategory &category); }; #endif diff --git a/runners/converter/converterrunner.cpp b/runners/converter/converterrunner.cpp --- a/runners/converter/converterrunner.cpp +++ b/runners/converter/converterrunner.cpp @@ -1,5 +1,6 @@ /* * Copyright (C) 2007,2008 Petri Damstén + * Copyright (C) 2020 Alexander Lohnau * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -15,304 +16,206 @@ * along with this program. If not, see . */ + #include "converterrunner.h" + #include #include #include -#include #include #include -#include -#include #include -#define CONVERSION_CHAR QLatin1Char( '>' ) - K_EXPORT_PLASMA_RUNNER(converterrunner, ConverterRunner) -class StringParser +ConverterRunner::ConverterRunner(QObject *parent, const QVariantList &args) + : Plasma::AbstractRunner(parent, args) { -public: - enum GetType - { - GetString = 1, - GetDigit = 2 - }; + setObjectName(QStringLiteral("Converter")); + //can not ignore commands: we have things like m4 + setIgnoredTypes(Plasma::RunnerContext::Directory | Plasma::RunnerContext::File | + Plasma::RunnerContext::NetworkLocation); - StringParser(const QString &s) : m_index(0), m_s(s) {} - ~StringParser() {} + const QString description = i18n("Converts the value of :q: when :q: is made up of " + "\"value unit [>, to, as, in] unit\". You can use the " + "Unit converter applet to find all available units."); + addSyntax(Plasma::RunnerSyntax(QStringLiteral(":q:"), description)); +} - QString get(int type) - { - QChar current; - QString result; +void ConverterRunner::init() +{ + m_separators << QStringLiteral("<"); + m_separators << i18nc("list of words that can used as amount of 'unit1' [in|to|as] 'unit2'", + "in;to;as").split(QLatin1Char(';')); - passWhiteSpace(); - while (true) { - current = next(); - if (current.isNull()) { - break; - } - if (current.isSpace()) { - break; - } - bool number = isNumber(current); - if (type == GetDigit && !number) { - break; - } - if (type == GetString && number) { - break; - } - if(current == QLatin1Char( CONVERSION_CHAR )) { - break; - } - ++m_index; - result += current; - } - return result; - } + const QList allLocales = QLocale::matchingLocales( + QLocale::AnyLanguage, QLocale::AnyScript, QLocale::AnyCountry); + KUnitConversion::UnitCategory currencyCategory = converter.category(QStringLiteral("Currency")); + const QStringList availableISOCodes = currencyCategory.allUnits(); + hasCurrencyRegex = QRegularExpression(QStringLiteral("\\p{Sc}")); + hasCurrencyRegex.optimize(); - bool isNumber(const QChar &ch) - { - if (ch.isNumber()) { - return true; + // Get all valid and by backend supported currency symbols + for (const auto ¤cyLocale: allLocales) { + const QString symbol = currencyLocale.currencySymbol(QLocale::CurrencySymbol); + const QString isoCode = currencyLocale.currencySymbol(QLocale::CurrencyIsoCode); + + if (isoCode.isEmpty() || !symbol.contains(hasCurrencyRegex)) { + continue; } - if (QStringLiteral(".,-+/").contains(ch)) { - return true; + if (availableISOCodes.contains(isoCode)) { + symbolToISOMap.insert(symbol, isoCode); } - return false; } - QString rest() - { - return m_s.mid(m_index).simplified(); + // Construct match regex, see https://regex101.com/r/EUDzQi/17 for demo + const QString valueGroup = QStringLiteral("(?P<%1>[0-9,./]+)").arg(valueKey); + const QString unitGroups = QStringLiteral("(?P<%1>[a-zA-Z/\"'^0-9\\p{Sc}]+)"); + const QString sourceUnitGroup = unitGroups.arg(sourceUnitKey); + const QString targetUnitGroup = unitGroups.arg(targetUnitKey); + const QString separatorsGroup = QStringLiteral("(?: in | to | as | ?> ?)"); + matchRegex = QRegularExpression(QStringLiteral("^%1 ?%2(?:%3%4)?$") + .arg(valueGroup, sourceUnitGroup, separatorsGroup, targetUnitGroup)); + matchRegex.optimize(); +} + +ConverterRunner::~ConverterRunner() = default; + +void ConverterRunner::match(Plasma::RunnerContext &context) +{ + const QString term = context.query(); + if (term.size() < 2 || !context.isValid()) { + return; } - void pass(const QStringList &strings) - { - passWhiteSpace(); - const QString temp = m_s.mid(m_index); + const QRegularExpressionMatch regexMatch = matchRegex.match(context.query()); + if (!regexMatch.hasMatch() || regexMatch.capturedTexts().size() < 2) { + return; + } + const QString inputValueString = regexMatch.captured(valueKey); - foreach (const QString& s, strings) { - if (temp.startsWith(s)) { - m_index += s.length(); - return; - } + // If the units contain a currency symbol convert them to ISO + QString inputUnitString = regexMatch.captured(sourceUnitKey); + if (inputUnitString.contains(hasCurrencyRegex)) { + inputUnitString = symbolToISOMap.value(inputUnitString.toUpper(), inputUnitString); + } + QString outputUnitString; + if (regexMatch.lastCapturedIndex() == 3) { + outputUnitString = regexMatch.captured(targetUnitKey); + if (outputUnitString.contains(hasCurrencyRegex)) { + outputUnitString = symbolToISOMap.value(outputUnitString.toUpper(), outputUnitString); } } -private: - void passWhiteSpace() - { - while (next().isSpace()) { - ++m_index; - } + KUnitConversion::UnitCategory inputCategory = converter.categoryForUnit(inputUnitString); + const KUnitConversion::Unit inputUnit = inputCategory.unit(inputUnitString); + const QList outputUnits = createResultUnits(outputUnitString, inputCategory); + const auto numberDataPair = getValidatedNumberValue(inputValueString); + // Return on invalid user input + if (!numberDataPair.first) { + return; } - QChar next() - { - if (m_index >= m_s.size()) { - return QChar::Null; + const double numberValue = numberDataPair.second; + QList matches; + for (const KUnitConversion::Unit &outputUnit: outputUnits) { + const KUnitConversion::Value &outputValue = inputCategory.convert( + KUnitConversion::Value(numberValue, inputUnit), outputUnit); + if (!outputValue.isValid() || inputUnit == outputUnit) { + continue; } - return m_s.at(m_index); + + Plasma::QueryMatch match(this); + match.setType(Plasma::QueryMatch::ExactMatch); + match.setIconName(QStringLiteral("edit-copy")); + match.setText(QStringLiteral("%1 (%2)").arg(outputValue.toString(), outputUnit.symbol())); + match.setData(outputValue.number()); + match.setRelevance(1.0 - std::abs(std::log10(outputValue.number())) / 50.0); + matches.append(match); } - int m_index; - QString m_s; -}; + context.addMatches(matches); +} -ConverterRunner::ConverterRunner(QObject* parent, const QVariantList &args) - : Plasma::AbstractRunner(parent, args) +void ConverterRunner::run(const Plasma::RunnerContext &context, const Plasma::QueryMatch &match) { - Q_UNUSED(args) - setObjectName(QLatin1String( "Converter" )); - - m_separators << QString( CONVERSION_CHAR ); - m_separators << i18nc("list of words that can used as amount of 'unit1' [in|to|as] 'unit2'", - "in;to;as").split(QLatin1Char( ';' )); - - //can not ignore commands: we have things like m4 - setIgnoredTypes(Plasma::RunnerContext::Directory | Plasma::RunnerContext::File | - Plasma::RunnerContext::NetworkLocation); + Q_UNUSED(context) - QString description = i18n("Converts the value of :q: when :q: is made up of " - "\"value unit [>, to, as, in] unit\". You can use the " - "Unit converter applet to find all available units."); - addSyntax(Plasma::RunnerSyntax(QLatin1String(":q:"), description)); + QGuiApplication::clipboard()->setText(match.data().toString()); } -ConverterRunner::~ConverterRunner() +QPair ConverterRunner::stringToDouble(const QStringRef &value) { + bool ok; + double numberValue = locale.toDouble(value, &ok); + if (!ok) { + numberValue = value.toDouble(&ok); + } + return {ok, numberValue}; } -void ConverterRunner::match(Plasma::RunnerContext &context) +QPair ConverterRunner::getValidatedNumberValue(const QString &value) { - const QString term = context.query(); - if (term.size() < 2) { - return; + const auto fractionParts = value.splitRef(QLatin1Char('/'), QString::SkipEmptyParts); + if (fractionParts.isEmpty() || fractionParts.count() > 2) { + return {false, 0}; } - StringParser cmd(term); - QString unit1; - QString value; - QString unit2; - - unit1 = cmd.get(StringParser::GetString); - value = cmd.get(StringParser::GetDigit); - if (value.isEmpty()) { - return; - } - if (unit1.isEmpty()) { - unit1 = cmd.get(StringParser::GetString | StringParser::GetDigit); - if (unit1.isEmpty()) { - return; + if (fractionParts.count() == 2) { + const QPair doubleFirstResults = stringToDouble(fractionParts.first()); + if (!doubleFirstResults.first) { + return {false, 0}; } - } - - const QString s = cmd.get(StringParser::GetString); - - if (!s.isEmpty() && !m_separators.contains(s)) { - unit1 += QLatin1Char( ' ' ) + s; - } - if (s.isEmpty() || !m_separators.contains(s)) { - cmd.pass(m_separators); - } - unit2 = cmd.rest(); - - KUnitConversion::Converter converter; - KUnitConversion::UnitCategory category = converter.categoryForUnit(unit1); - bool found = false; - if (category.id() == KUnitConversion::InvalidCategory) { - foreach (category, converter.categories()) { - foreach (const QString& s, category.allUnits()) { - if (s.compare(unit1, Qt::CaseInsensitive) == 0) { - unit1 = s; - found = true; - break; - } - } - if (found) { - break; - } + const QPair doubleSecondResult = stringToDouble(fractionParts.last()); + if (!doubleSecondResult.first || qFuzzyIsNull(doubleSecondResult.second)) { + return {false, 0}; } - if (!found) { - return; + + return {true, doubleFirstResults.second / doubleSecondResult.second}; + } else if (fractionParts.count() == 1) { + const QPair doubleResult = stringToDouble(fractionParts.first()); + if (!doubleResult.first) { + return {false, 0}; } + return {true, doubleResult.second}; + } else { + return {true, 0}; } - +} +QList ConverterRunner::createResultUnits(const QString &outputUnitString, + const KUnitConversion::UnitCategory &category) +{ QList units; - - if (!unit2.isEmpty()) { - KUnitConversion::Unit u = category.unit(unit2); - if (!u.isNull() && u.isValid()) { - units.append(u); - config().writeEntry(category.name(), u.symbol()); + if (!outputUnitString.isEmpty()) { + KUnitConversion::Unit outputUnit = category.unit(outputUnitString); + if (!outputUnit.isNull() && outputUnit.isValid()) { + units.append(outputUnit); } else { const QStringList unitStrings = category.allUnits(); - QList matchingUnits; - foreach (const QString& s, unitStrings) { - if (s.startsWith(unit2, Qt::CaseInsensitive)) { - u = category.unit(s); - if (!matchingUnits.contains(u)) { - matchingUnits << u; + for (const QString &unitString: unitStrings) { + if (unitString.startsWith(outputUnitString, Qt::CaseInsensitive)) { + outputUnit = category.unit(unitString); + if (!units.contains(outputUnit)) { + units << outputUnit; } } } - units = matchingUnits; - if (units.count() == 1) { - config().writeEntry(category.name(), units[0].symbol()); - } } } else { units = category.mostCommonUnits(); - KUnitConversion::Unit u = category.unit(config().readEntry(category.name())); - if (!u.isNull() && units.indexOf(u) < 0) { - units << u; - } - // suggest converting to the user's local currency if (category.id() == KUnitConversion::CurrencyCategory) { const QString ¤cyIsoCode = QLocale().currencySymbol(QLocale::CurrencyIsoCode); - KUnitConversion::Unit localCurrency = category.unit(currencyIsoCode); + const KUnitConversion::Unit localCurrency = category.unit(currencyIsoCode); if (localCurrency.isValid() && !units.contains(localCurrency)) { units << localCurrency; } } } - QList matches; - - QLocale locale; - auto stringToDouble = [&locale](const QStringRef &value, bool *ok) { - double numberValue = locale.toDouble(value, ok); - if (!(*ok)) { - numberValue = value.toDouble(ok); - } - return numberValue; - }; - - KUnitConversion::Unit u1 = category.unit(unit1); - foreach (const KUnitConversion::Unit& u, units) { - if (u1 == u) { - continue; - } - - double numberValue = 0.0; - - const auto fractionParts = value.splitRef(QLatin1Char('/'), QString::SkipEmptyParts); - if (fractionParts.isEmpty() || fractionParts.count() > 2) { - continue; - } - - if (fractionParts.count() == 2) { - bool ok; - const double numerator = stringToDouble(fractionParts.first(), &ok); - if (!ok) { - continue; - } - const double denominator = stringToDouble(fractionParts.last(), &ok); - if (!ok || qFuzzyIsNull(denominator)) { - continue; - } - - numberValue = numerator / denominator; - } else if (fractionParts.count() == 1) { - bool ok; - numberValue = stringToDouble(fractionParts.first(), &ok); - if (!ok) { - continue; - } - } - - KUnitConversion::Value v = category.convert(KUnitConversion::Value(numberValue, u1), u); - - if (!v.isValid()) { - continue; - } - - Plasma::QueryMatch match(this); - match.setType(Plasma::QueryMatch::InformationalMatch); - match.setIconName(QStringLiteral("edit-copy")); - match.setText(QStringLiteral("%1 (%2)").arg(v.toString(), u.symbol())); - match.setData(v.number()); - match.setRelevance(1.0 - std::abs(std::log10(v.number())) / 50.0); - matches.append(match); - } - - context.addMatches(matches); -} - -void ConverterRunner::run(const Plasma::RunnerContext &context, const Plasma::QueryMatch &match) -{ - Q_UNUSED(context) - const QString data = match.data().toString(); - if (data.startsWith(QLatin1String("http://"))) { - QDesktopServices::openUrl(QUrl(data)); - } else { - QGuiApplication::clipboard()->setText(data); - } + return units; } #include "converterrunner.moc"