// SPDX-FileCopyrightText: 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> // // SPDX-License-Identifier: GPL-3.0-only /* * Prism Launcher - Minecraft Launcher * Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include #include #include #include #include #include #include #include #include #include #include class XmlLogParseTest : public QObject { Q_OBJECT private slots: void guessLevel_timestampFormats() { QCOMPARE(LogParser::guessLevel("[21:16:07] [Server thread/WARN]: short timestamp", MessageLevel::Unknown), MessageLevel::Warning); QCOMPARE(LogParser::guessLevel("[23Jul2026 18:12:07.877] [main/WARN] [Sodium-Workarounds/]: date and millis timestamp", MessageLevel::Unknown), MessageLevel::Warning); QCOMPARE(LogParser::guessLevel( "[25Jul2026 14:10:58.723] [main/ERROR] [net.minecraftforge.fml.loading.moddiscovery.ModFileParser/LOADING]: error", MessageLevel::Unknown), MessageLevel::Error); } void parseEventTimestamp() { // Taken verbatim from testdata/TestLogs/vanilla-1.21.5.xml.log. These two events sit just under // two seconds apart, as they do in the plain text capture of the same startup sequence - read as // seconds they would be 33 minutes apart, in the year 57267. const QStringList lines = { R"( )", R"( )", R"( )", R"( )", R"( )", R"( )", }; LogParser parser; QList timestamps; for (const auto& line : lines) { parser.appendLine(line); for (const auto& item : parser.parseAvailable()) { QVERIFY(std::holds_alternative(item)); timestamps.append(std::get(item).timestamp); } } QCOMPARE(timestamps.length(), 2); // Comparing instants rather than rendered clock times keeps this independent of the time zone. QCOMPARE(timestamps[0], QDateTime::fromMSecsSinceEpoch(1745005148589)); QCOMPARE(timestamps[1], QDateTime::fromMSecsSinceEpoch(1745005150587)); QCOMPARE(timestamps[0].toUTC().date(), QDate(2025, 4, 18)); QCOMPARE(timestamps[0].msecsTo(timestamps[1]), 1998); } void parseXml_data() { QString source = QFINDTESTDATA("testdata/TestLogs"); QString shortXml = readTextFile(FS::PathCombine(source, "vanilla-1.21.5.xml.log")); QString shortText = readTextFile(FS::PathCombine(source, "vanilla-1.21.5.text.log")); QStringList shortTextLevels_s = readTextFile(FS::PathCombine(source, "vanilla-1.21.5-levels.txt")).split(QRegularExpression("\n|\r\n|\r"), Qt::SkipEmptyParts); QList shortTextLevels; shortTextLevels.reserve(24); std::transform(shortTextLevels_s.cbegin(), shortTextLevels_s.cend(), std::back_inserter(shortTextLevels), [](const QString& line) { return MessageLevel::fromName(line.trimmed()); }); QString longXml = readTextFile(FS::PathCombine(source, "TerraFirmaGreg-Modern-forge.xml.log")); QString longText = readTextFile(FS::PathCombine(source, "TerraFirmaGreg-Modern-forge.text.log")); QStringList longTextLevels_s = readTextFile(FS::PathCombine(source, "TerraFirmaGreg-Modern-levels.txt")) .split(QRegularExpression("\n|\r\n|\r"), Qt::SkipEmptyParts); QStringList longTextLevelsXml_s = readTextFile(FS::PathCombine(source, "TerraFirmaGreg-Modern-xml-levels.txt")) .split(QRegularExpression("\n|\r\n|\r"), Qt::SkipEmptyParts); QList longTextLevelsPlain; longTextLevelsPlain.reserve(974); std::transform(longTextLevels_s.cbegin(), longTextLevels_s.cend(), std::back_inserter(longTextLevelsPlain), [](const QString& line) { return MessageLevel::fromName(line.trimmed()); }); QList longTextLevelsXml; longTextLevelsXml.reserve(896); std::transform(longTextLevelsXml_s.cbegin(), longTextLevelsXml_s.cend(), std::back_inserter(longTextLevelsXml), [](const QString& line) { return MessageLevel::fromName(line.trimmed()); }); QTest::addColumn("log"); QTest::addColumn("num_entries"); QTest::addColumn>("entry_levels"); QTest::newRow("short-vanilla-plain") << shortText << 25 << shortTextLevels; QTest::newRow("short-vanilla-xml") << shortXml << 25 << shortTextLevels; QTest::newRow("long-forge-plain") << longText << 945 << longTextLevelsPlain; QTest::newRow("long-forge-xml") << longXml << 869 << longTextLevelsXml; } void parseXml() { QFETCH(QString, log); QFETCH(int, num_entries); QFETCH(QList, entry_levels); QList> entries = {}; QBENCHMARK { entries = parseLines(log.split(QRegularExpression("\n|\r\n|\r"))); } QCOMPARE(entries.length(), num_entries); QList levels = {}; std::ranges::transform(entries, std::back_inserter(levels), [](const auto& entry) { return entry.first; }); QCOMPARE(levels, entry_levels); } void parseAngleBrackets_data() { QTest::addColumn("lines"); QTest::addColumn("messages"); // Text that merely begins to look like a log4j event must not be held back: lines reach the // parser whole, so the rest of `w<", "[21:16:08] [Render thread/INFO]: unrelated" } << QStringList{ "[21:16:07] [Render thread/INFO]: happy >w<", "[21:16:08] [Render thread/INFO]: unrelated" }; QTest::newRow("trailing partial tag") << QStringList{ "generics are ", "unrelated" } << QStringList{ "", "unrelated" }; QTest::newRow("longer element name") << QStringList{ "talking about )", R"( )", R"( )" } << QStringList{ "Setting user: Ryexandrite" }; QTest::newRow("event with attributes on the next line") << QStringList{ R"( )", R"( )", R"( )" } << QStringList{ "Setting user: Ryexandrite" }; QTest::newRow("event preceded by text") << QStringList{ R"(stray output )" R"()" } << QStringList{ "stray output ", "Setting user: Ryexandrite" }; } void parseAngleBrackets() { QFETCH(QStringList, lines); QFETCH(QStringList, messages); QCOMPARE(parseMessages(lines), messages); } private: QString readTextFile(const QString& path) const { return QString::fromUtf8(FS::read(path).value()); } QList> parseLines(const QStringList& lines) { LogParser parser; QList> out; MessageLevel last = MessageLevel::Unknown; for (const auto& line : lines) { parser.appendLine(line); auto items = parser.parseAvailable(); for (const auto& item : items) { if (std::holds_alternative(item)) { auto entry = std::get(item); auto msg = QString("[%1] [%2/%3] [%4]: %5") .arg(entry.timestamp.toString("HH:mm:ss")) .arg(entry.thread) .arg(entry.levelText) .arg(entry.logger) .arg(entry.message); out.append(std::make_pair(entry.level, msg)); last = entry.level; } else if (std::holds_alternative(item)) { auto msg = std::get(item).message; auto level = LogParser::guessLevel(msg, last); out.append(std::make_pair(level, msg)); last = level; } } } return out; } /// The messages the parser produces, without the timestamp formatting parseLines() applies - so that /// expectations do not depend on the time zone the test happens to run in. QStringList parseMessages(const QStringList& lines) { LogParser parser; QStringList out; for (const auto& line : lines) { parser.appendLine(line); for (const auto& item : parser.parseAvailable()) { if (std::holds_alternative(item)) { out.append(std::get(item).message); } else if (std::holds_alternative(item)) { out.append(std::get(item).message); } } } return out; } }; QTEST_GUILESS_MAIN(XmlLogParseTest) #include "XmlLogs_test.moc"