Files
BiLauncher/tests/unit/tst_buildswitcher.cpp
galeon 41599fa82f
tests / linux (push) Failing after 2m18s
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
new unit tests, hehe 67
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 13:14:08 +03:00

303 lines
14 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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"