#include "testenv.h" #include "fixtures.h" #include "buildswitcher.h" #include "launcherpaths.h" #include "localization.h" #include #include #include class TestBuildSwitcher : public QObject { Q_OBJECT QString m_game; static QJsonObject index() { return Fixtures::readJsonFile(LauncherPaths::buildStorageDir() + "/index.json"); } static QStringList zipsOf(int buildId) { return QDir(LauncherPaths::buildDir(buildId)).entryList({"*.zip"}, QDir::Files | QDir::Hidden); } // Ждёт finished или failed; возвращает true при finished(expectedId). static bool waitDone(QSignalSpy &finished, QSignalSpy &failed, int expectedId) { if (!QTest::qWaitFor([&] { return finished.count() + failed.count() > 0; }, 20000)) { qWarning("ни finished, ни failed за 20 с"); return false; } if (!failed.isEmpty()) { qWarning("failed: %s", qPrintable(failed.constFirst().at(1).toString())); return false; } return finished.constFirst().constFirst().toInt() == expectedId; } void writeShared() { Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}"); Fixtures::write(m_game + "/libraries/x/y.jar", "lib"); Fixtures::write(m_game + "/assets/indexes/5.json", "{}"); Fixtures::write(m_game + "/launcher_profiles.json", "{}"); } private slots: void init() { TestEnv::resetLauncherRoot(); m_game = TestEnv::freshDir(QStringLiteral("game")); } void sharedTopLevelIsStable() { QStringList shared = BuildSwitcher::sharedTopLevel(); shared.sort(); QCOMPARE(shared, QStringList({"assets", "launcher_profiles.json", "libraries", "runtime", "versions"})); } void switchBackAndForthKeepsEachBuildContent() { writeShared(); Fixtures::write(m_game + "/mods/alpha.jar", "alpha"); Fixtures::write(m_game + "/saves/A/level.dat", "A"); Fixtures::write(m_game + "/options.txt", "alpha-options"); BuildSwitcher switcher; QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); // 1 → 2: у сборки 2 архива ещё нет, папка игры очищается до общих файлов. switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game); QVERIFY(switcher.isRunning()); QVERIFY(waitDone(finished, failed, 2)); QVERIFY(!switcher.isRunning()); QCOMPARE(Fixtures::listTree(m_game), QStringList({"assets/indexes/5.json", "launcher_profiles.json", "libraries/x/y.jar", "versions/1.20.1/1.20.1.json"})); QVERIFY(switcher.hasArchive(1)); QVERIFY(!switcher.hasArchive(2)); const QStringList zips = zipsOf(1); QCOMPARE(zips.size(), 1); QVERIFY(zips.constFirst().endsWith("_Alpha_build.zip")); // имя из названия сборки QJsonObject idx = index(); QCOMPARE(idx.value("state").toString(), QStringLiteral("idle")); QCOMPARE(idx.value("active").toInt(), 2); QCOMPARE(idx.value("builds").toObject().value("1").toObject().value("name").toString(), QStringLiteral("Alpha build")); // Наполняем сборку 2 и возвращаемся в 1. Fixtures::write(m_game + "/mods/beta.jar", "beta"); finished.clear(); switcher.switchTo(2, QStringLiteral("Beta"), 1, QStringLiteral("Alpha build"), m_game); QVERIFY(waitDone(finished, failed, 1)); QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("alpha")); QCOMPARE(Fixtures::readFile(m_game + "/saves/A/level.dat"), QByteArray("A")); QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("alpha-options")); QVERIFY(!QFile::exists(m_game + "/mods/beta.jar")); QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); // общее не тронуто QVERIFY(switcher.hasArchive(2)); // И снова в 2 — его моды на месте. finished.clear(); switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game); QVERIFY(waitDone(finished, failed, 2)); QCOMPARE(Fixtures::readFile(m_game + "/mods/beta.jar"), QByteArray("beta")); QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar")); // У сборки хранится один актуальный архив, временных не остаётся. QCOMPARE(zipsOf(1).size(), 1); QVERIFY(zipsOf(1).filter(".tmp-").isEmpty()); } void switchWithoutSourceBuildOnlyClears() { writeShared(); Fixtures::write(m_game + "/mods/stray.jar", "x"); BuildSwitcher switcher; QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); switcher.switchTo(-1, QString(), 5, QStringLiteral("Five"), m_game); QVERIFY(waitDone(finished, failed, 5)); QVERIFY(!QFile::exists(m_game + "/mods/stray.jar")); QVERIFY(QFile::exists(m_game + "/libraries/x/y.jar")); QVERIFY(QDir(LauncherPaths::buildStorageDir()).entryList({"*.zip"}).isEmpty()); } void secondSwitchWhileBusyIsRejected() { writeShared(); for (int i = 0; i < 50; ++i) Fixtures::write(m_game + QStringLiteral("/mods/m%1.jar").arg(i), QByteArray(4096, 'x')); BuildSwitcher switcher; QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); switcher.switchTo(1, "A", 2, "B", m_game); switcher.switchTo(1, "A", 3, "C", m_game); QCOMPARE(failed.count(), 1); QCOMPARE(failed.constFirst().at(0).toInt(), 3); QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("common.error.switchInProgress")); QCOMPARE(failed.constFirst().at(2).toBool(), true); QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000); } void interruptedSwitchIsReportedAndResumed() { writeShared(); Fixtures::write(m_game + "/mods/half-cleared.jar", "x"); // Архив сборки 7 записан, переключение 3 → 7 оборвалось посреди очистки. QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(7) + "/2026-01-01_Seven.zip", {{"mods/seven.jar", "seven"}, {"options.txt", "seven-options"}})); Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json", QJsonObject{{"state", "switching"}, {"from", 3}, {"to", 7}, {"builds", QJsonObject{{"7", QJsonObject{{"name", "Seven"}, {"archive", "2026-01-01_Seven.zip"}}}}}}); // Мусор от прерванной архивации подметается конструктором. Fixtures::write(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip", "partial"); BuildSwitcher switcher; QVERIFY(!QFile::exists(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip")); const QString warning = switcher.interruptedSwitchWarning(); QCOMPARE(warning, Loc::text("switch.status.switchInterrupted") .arg(Loc::text("switch.status.targetSuffix").arg("Seven"))); QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); switcher.resumeInterrupted(m_game); QVERIFY(waitDone(finished, failed, 7)); QVERIFY(!QFile::exists(m_game + "/mods/half-cleared.jar")); QCOMPARE(Fixtures::readFile(m_game + "/mods/seven.jar"), QByteArray("seven")); QCOMPARE(index().value("state").toString(), QStringLiteral("idle")); QVERIFY(switcher.interruptedSwitchWarning().isEmpty()); } void applyPackAndRecordNote() { writeShared(); Fixtures::write(m_game + "/mods/old.jar", "old"); const QString pack = LauncherPaths::buildDir(9) + "/season-pack.zip"; QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"config/c.toml", "c"}})); BuildSwitcher switcher; QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); const QJsonObject note{{"seasonalId", "season-5"}, {"revision", 3}}; switcher.applyPack(9, QStringLiteral("Season 5"), pack, {"mods/old.jar"}, note, m_game); QVERIFY(waitDone(finished, failed, 9)); QVERIFY(!QFile::exists(m_game + "/mods/old.jar")); QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new")); QVERIFY(!QFile::exists(pack)); // пакет удаляется после установки QStringList entries = switcher.lastPackEntries(); entries.sort(); QCOMPARE(entries, QStringList({"config/c.toml", "mods/new.jar"})); QCOMPARE(switcher.lastPackNote(), note); const QJsonObject idx = index(); QCOMPARE(idx.value("state").toString(), QStringLiteral("idle")); QCOMPARE(idx.value("active").toInt(), 9); QVERIFY(!idx.contains("pack")); } void applyPackErrors() { BuildSwitcher switcher; QSignalSpy failed(&switcher, &BuildSwitcher::failed); const QString missing = LauncherPaths::buildDir(4) + "/nope.zip"; switcher.applyPack(4, "Four", missing, {}, {}, m_game); QCOMPARE(failed.count(), 1); QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("switch.error.archiveMissing").arg(QDir::toNativeSeparators(missing))); // Пакет лезет в общие папки лаунчера — отказ, папка игры цела. const QString bad = LauncherPaths::buildDir(4) + "/bad.zip"; QVERIFY(Fixtures::makeZip(bad, {{"versions/evil/evil.json", "{}"}})); failed.clear(); switcher.applyPack(4, "Four", bad, {}, {}, m_game); QTRY_COMPARE_WITH_TIMEOUT(failed.count(), 1, 20000); QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("switch.error.packTouchesLauncher").arg("versions/evil/evil.json")); QCOMPARE(failed.constFirst().at(2).toBool(), true); } void interruptedApplyIsResumedOrCleaned() { writeShared(); const QString pack = LauncherPaths::buildDir(11) + "/pack.zip"; QVERIFY(Fixtures::makeZip(pack, {{"mods/resumed.jar", "r"}})); const QJsonObject note{{"k", "v"}}; Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json", QJsonObject{{"state", "applying"}, {"to", 11}, {"pack", "pack.zip"}, {"packName", "Eleven"}, {"packRemove", QJsonArray{"mods/gone.jar"}}, {"packNote", note}}); Fixtures::write(m_game + "/mods/gone.jar", "g"); { BuildSwitcher switcher; QCOMPARE(switcher.interruptedSwitchWarning(), Loc::text("switch.status.applyInterrupted") .arg(Loc::text("switch.status.nameSuffix").arg("Eleven"))); QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); switcher.resumeInterrupted(m_game); QVERIFY(waitDone(finished, failed, 11)); QVERIFY(QFile::exists(m_game + "/mods/resumed.jar")); QVERIFY(!QFile::exists(m_game + "/mods/gone.jar")); QCOMPARE(switcher.lastPackNote(), note); } // Пакет пропал — индекс просто приводится в порядок, без сигналов. Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json", QJsonObject{{"state", "applying"}, {"to", 12}, {"pack", "lost.zip"}}); BuildSwitcher switcher; QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy failed(&switcher, &BuildSwitcher::failed); switcher.resumeInterrupted(m_game); QCOMPARE(index().value("state").toString(), QStringLiteral("idle")); QVERIFY(!index().contains("pack")); QCOMPARE(finished.count() + failed.count(), 0); } void forgetBuild() { QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(5) + "/a.zip", {{"x", "y"}})); Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json", QJsonObject{{"state", "idle"}, {"active", 5}, {"builds", QJsonObject{{"5", QJsonObject{{"name", "Five"}}}, {"6", QJsonObject{{"name", "Six"}}}}}}); BuildSwitcher switcher; QVERIFY(switcher.hasArchive(5)); QVERIFY(switcher.forgetBuild(5)); QVERIFY(!QDir(LauncherPaths::buildDir(5)).exists()); QVERIFY(!switcher.hasArchive(5)); const QJsonObject idx = index(); QVERIFY(!idx.contains("active")); QVERIFY(!idx.value("builds").toObject().contains("5")); QVERIFY(idx.value("builds").toObject().contains("6")); QVERIFY(switcher.forgetBuild(-1)); } void fractionAndStage() { BuildSwitcher switcher; QCOMPARE(switcher.fraction(), -1.0); QVERIFY(switcher.stage().isEmpty()); writeShared(); Fixtures::write(m_game + "/mods/a.jar", "a"); QSignalSpy finished(&switcher, &BuildSwitcher::finished); QSignalSpy progress(&switcher, &BuildSwitcher::progressChanged); switcher.switchTo(1, "A", 2, "B", m_game); QCOMPARE(switcher.stage(), Loc::text("switch.progress.saving").arg("A")); QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000); QVERIFY(progress.count() > 2); QCOMPARE(switcher.fraction(), -1.0); } }; BILAUNCHER_TEST_MAIN(TestBuildSwitcher) #include "tst_buildswitcher.moc"