#include "testenv.h" #include "fakemojang.h" #include "fixtures.h" #include "nettest.h" #include "gamelauncher.h" #include "localization.h" #include "minecraftversion.h" #include "versioninstaller.h" #include "versionmanifestservice.h" #include #include class TestVersionInstaller : public QObject { Q_OBJECT FakeInternet m_net; QString m_game; struct Run { explicit Run(VersionInstaller &installer) : started(&installer, &VersionInstaller::started) , finished(&installer, &VersionInstaller::finished) , failed(&installer, &VersionInstaller::failed) , canceled(&installer, &VersionInstaller::canceled) , progress(&installer, &VersionInstaller::progressChanged) {} bool waitEnd(int expectedEvents = 1) { return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() >= expectedEvents; }, 30000); } QString failure() const { return failed.isEmpty() ? QString() : failed.constLast().at(1).toString(); } QSignalSpy started, finished, failed, canceled, progress; }; void verifyInstalled(const FakeMojang &mojang, const QString &id) { const auto expected = mojang.expectedFiles(id); QVERIFY(!expected.isEmpty()); for (auto it = expected.cbegin(); it != expected.cend(); ++it) { const QString path = m_game + u'/' + it.key(); QVERIFY2(QFile::exists(path), qPrintable("нет файла " + it.key())); QCOMPARE(Fixtures::readFile(path), it.value()); } } private slots: void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); } void init() { TestEnv::resetLauncherRoot(); m_net.reset(); m_game = TestEnv::freshDir(QStringLiteral("game")); } void installsModernVersionCompletely() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(installer.isRunning()); QCOMPARE(installer.versionId(), QStringLiteral("1.20.1")); QVERIFY(run.waitEnd()); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); QCOMPARE(run.started.count(), 1); QCOMPARE(run.finished.constFirst().constFirst().toString(), QStringLiteral("1.20.1")); QVERIFY(!installer.isRunning()); QVERIFY(run.progress.count() > 0); QVERIFY(m_net.unmatched().isEmpty()); verifyInstalled(mojang, "1.20.1"); // Результат пригоден для запуска: GameLauncher не видит недостающих файлов. QString error; const MinecraftVersion version = VersionLoader::load(m_game, "1.20.1", {}, &error); QVERIFY2(version.isValid(), qPrintable(error)); LaunchOptions options; options.gameDir = m_game; QVERIFY(GameLauncher::missingFiles(options, version).isEmpty()); } void secondInstallSkipsExistingFiles() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); VersionManifestService manifest; VersionInstaller installer(&manifest); { Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY(run.failed.isEmpty()); } const int firstRequests = m_net.requests().size(); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY(run.failed.isEmpty()); // Повторно скачивается только json версии; файлы нужного размера пропускаются. const int extra = m_net.requests().size() - firstRequests; QVERIFY2(extra <= 1, qPrintable(QString::number(extra))); } void legacyAssetsAreCopiedToVirtual() { FakeMojang mojang(m_net); mojang.publish({.id = "1.5.2", .legacyAssets = true, .javaMajor = 8}); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.5.2"); QVERIFY(run.waitEnd()); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); verifyInstalled(mojang, "1.5.2"); // включая assets/virtual/legacy/* } void inheritedVersionPullsParentFirst() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); mojang.publish({.id = "fabric-loader-0.16.10-1.20.1", .inheritsFrom = "1.20.1"}); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "fabric-loader-0.16.10-1.20.1"); QVERIFY(run.waitEnd(2)); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); // started приходит на каждую версию, которую очередь берёт «с нуля». QCOMPARE(run.started.constFirst().constFirst().toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1")); QStringList finishedIds; for (const auto &call : run.finished) finishedIds << call.constFirst().toString(); QCOMPARE(finishedIds, QStringList({"1.20.1", "fabric-loader-0.16.10-1.20.1"})); verifyInstalled(mojang, "1.20.1"); verifyInstalled(mojang, "fabric-loader-0.16.10-1.20.1"); } void localJsonUsedWhenNotInManifest() { // Версия не из манифеста (например, профиль модлоадера), но json уже лежит локально. FakeMojang mojang(m_net); mojang.publish({.id = "custom-1", .listed = false}); Fixtures::write(m_game + "/versions/custom-1/custom-1.json", mojang.versionJson("custom-1")); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "custom-1"); QVERIFY(run.waitEnd()); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); verifyInstalled(mojang, "custom-1"); QCOMPARE(m_net.hits(mojang.versionJsonUrl("custom-1")), 0); } void unknownVersionFails() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "9.9.9"); QVERIFY(run.waitEnd()); QCOMPARE(run.failure(), Loc::text("version.error.notInManifest").arg("9.9.9")); } void invalidNamesRejected_data() { QTest::addColumn("id"); QTest::newRow("traversal") << "../evil"; QTest::newRow("slash") << "a/b"; QTest::newRow("dot") << "."; QTest::newRow("colon") << "c:x"; } void invalidNamesRejected() { QFETCH(QString, id); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, id); QCOMPARE(run.failed.count(), 1); QCOMPARE(run.failure(), Loc::text("version.error.invalidName").arg(id)); QVERIFY(m_net.requests().isEmpty()); installer.install(m_game, " "); // пустой id молча игнорируется QCOMPARE(run.failed.count(), 1); } void versionJsonChecksumMismatch() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); m_net.route(mojang.versionJsonUrl("1.20.1"), FakeResponse::json("{\"tampered\":true}")); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QCOMPARE(run.failure(), Loc::text("version.error.descriptionChecksum").arg("1.20.1")); QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); } void transientErrorIsRetried() { // Сбой не на последнем файле: пока ждём повтора, остальные ещё качаются. FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"); const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar"); FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar)); slowJar.delayMs = 1500; mojang.overrideFile(jar, slowJar); int attempts = 0; m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) { return ++attempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes("brigadier"); }); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); QCOMPARE(attempts, 2); verifyInstalled(mojang, "1.20.1"); } void retryOfLastFileIsNotLost() { // Сбой на последнем файле: остальные уже скачаны, повтор стоит в таймере. FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"); int attempts = 0; m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) { if (++attempts > 1) return FakeResponse::bytes("brigadier"); FakeResponse late = FakeResponse::error(503); late.delayMs = 1000; // все прочие файлы успевают докачаться return late; }); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); // retryOrFail() откладывает повтор на 400 мс, а handleReplyFinished() тут же // зовёт pump(): очередь и активные загрузки пусты — установка «завершается» // и падает на проверке файлов, повтор так и не происходит. QEXPECT_FAIL("", "повтор последнего файла теряется: pump() завершает установку раньше таймера", Abort); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); QCOMPARE(attempts, 2); } void checksumMismatchRetriesThenFails() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"); const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar"); // client.jar держим долго, чтобы все повторы библиотеки шли при живой установке. FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar)); slowJar.delayMs = 4000; mojang.overrideFile(jar, slowJar); mojang.overrideFile(lib, FakeResponse::bytes("corrupted")); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY2(run.failure().startsWith(Loc::text("common.error.downloadFailed") .arg("com.mojang:brigadier:1.1.8", Loc::text("common.error.checksumMismatch"))), qPrintable(run.failure())); QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 3); // kMaxAttempts QVERIFY(!QFile::exists(m_game + u'/' + lib)); // битый файл не сохранён } void permanentErrorFailsImmediately() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"); mojang.overrideFile(lib, FakeResponse::error(404)); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY(!run.failed.isEmpty()); QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 1); } void cancelStopsDownloads() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); FakeResponse hang; hang.hang = true; mojang.overrideFile("versions/1.20.1/1.20.1.jar", hang); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QTRY_VERIFY_WITH_TIMEOUT(m_net.hits(mojang.fileUrl("versions/1.20.1/1.20.1.jar")) == 1, 15000); installer.cancel(); QVERIFY(run.waitEnd()); QCOMPARE(run.canceled.count(), 1); QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("1.20.1")); QVERIFY(run.finished.isEmpty()); QVERIFY(!installer.isRunning()); QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar")); } void queueRunsSequentiallyAndDeduplicates() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"}); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); installer.install(m_game, "1.19.4"); installer.install(m_game, "1.19.4"); // дубль в очереди installer.install(m_game, "1.20.1"); // уже ставится QVERIFY(installer.isQueued("1.19.4")); QVERIFY(!installer.isQueued("1.20.1")); QVERIFY(run.waitEnd(2)); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); QCOMPARE(run.finished.count(), 2); QCOMPARE(run.started.count(), 2); verifyInstalled(mojang, "1.19.4"); } void offlineWithLocalJsonStillInstalls() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", mojang.versionJson("1.20.1")); m_net.route(FakeMojang::manifestUrl(), FakeResponse::error(503)); VersionManifestService manifest; VersionInstaller installer(&manifest); Run run(installer); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure())); verifyInstalled(mojang, "1.20.1"); } void fractionReportsProgress() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); VersionManifestService manifest; VersionInstaller installer(&manifest); QCOMPARE(installer.fraction(), -1.0); Run run(installer); double maxFraction = -1; connect(&installer, &VersionInstaller::progressChanged, this, [&] { maxFraction = std::max(maxFraction, installer.fraction()); QVERIFY(installer.fraction() <= 1.0); }); installer.install(m_game, "1.20.1"); QVERIFY(run.waitEnd()); QVERIFY(run.failed.isEmpty()); QVERIFY(maxFraction > 0.0); } }; BILAUNCHER_TEST_MAIN(TestVersionInstaller) #include "tst_versioninstaller.moc"