Files

302 lines
14 KiB
C++
Raw Permalink Normal View History

2026-10-12 13:14:08 +03:00
#include "testenv.h"
#include "fixtures.h"
#include "buildswitcher.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QJsonArray>
#include <QSignalSpy>
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"