new unit tests, hehe 67
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

Signed-off-by: galeon <dertiellis@gmail.com>
This commit is contained in:
galeon committed 2026-10-12 13:14:08 +03:00
1 parent ebf8ff7869
commit 41599fa82f
67 files changed
+8775 -4

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# QML: Qt Quick Test для компонентов и диалогов + дымовой тест главного окна.
bilauncher_add_test(tst_qml LABEL qml GUI TIMEOUT 180 SOURCES tst_qml.cpp)
target_compile_definitions(tst_qml PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
# Сами тесты лежат рядом, в tst_*.qml; Qt Creator покажет их в проекте.
target_sources(tst_qml PRIVATE tst_components.qml tst_dialogs.qml)
set_source_files_properties(tst_components.qml tst_dialogs.qml PROPERTIES HEADER_FILE_ONLY TRUE)
bilauncher_add_test(tst_qml_smoke LABEL qml GUI TIMEOUT 120 SOURCES tst_qml_smoke.cpp)
+155
View File
@@ -0,0 +1,155 @@
import QtQuick
import QtTest
import BiLauncher
// Маленькие переиспользуемые компоненты: DarkCombo, LabelledField,
// ProgressPanel, LoaderRow.
TestCase {
id: testCase
name: "Components"
width: 600
height: 400
visible: true
when: windowShown
Component { id: comboComponent; DarkCombo { width: 200; height: 32 } }
Component { id: fieldComponent; LabelledField { width: 220 } }
Component { id: panelComponent; ProgressPanel {} }
Component { id: loaderRowComponent; LoaderRow { width: 300 } }
// Подставной backend для LoaderRow: те же методы и сигнал, что у LauncherBackend.
Component {
id: fakeBackendComponent
QtObject {
property var versions: ({})
property bool loading: false
property var refreshCalls: []
signal loaderVersionsChanged(string loaderKey, string gameVersion)
function loaderVersions(key, game) { return versions[key + "/" + game] || [] }
function refreshLoaderVersions(key, game) { refreshCalls.push(key + "/" + game) }
function loaderVersionsLoading(key, game) { return loading }
}
}
// --- DarkCombo -------------------------------------------------------------
function test_comboSelectsAndDisplays() {
const combo = createTemporaryObject(comboComponent, testCase, { model: ["Alpha", "Beta", "Gamma"] })
verify(combo)
compare(combo.count, 3)
compare(combo.displayText, "Alpha")
const activated = signalSpy.createObject(testCase, { target: combo, signalName: "activated" })
mouseClick(combo)
tryVerify(() => combo.popup.opened)
combo.currentIndex = 2
compare(combo.displayText, "Gamma")
combo.popup.close()
tryVerify(() => !combo.popup.visible)
// Клавиатура: стрелки меняют выбор и шлют activated.
combo.forceActiveFocus()
keyClick(Qt.Key_Up)
compare(combo.currentIndex, 1)
compare(activated.count, 1)
verify(combo.indicator.source.toString().length > 0)
}
// --- LabelledField -------------------------------------------------------
function test_labelledFieldAliasesAndSignal() {
const field = createTemporaryObject(fieldComponent, testCase, { label: "Ник", placeholder: "Steve" })
verify(field)
compare(field.children[0].text, "Ник")
const input = field.children[1]
compare(input.placeholderText, "Steve")
const finished = signalSpy.createObject(testCase, { target: field, signalName: "editingFinished" })
input.forceActiveFocus()
keyClick(Qt.Key_A)
keyClick(Qt.Key_B)
compare(field.text, "ab")
keyClick(Qt.Key_Return)
compare(finished.count, 1)
field.text = "preset"
compare(input.text, "preset")
}
// --- ProgressPanel -------------------------------------------------------
function test_progressPanelPercentAndCancel() {
const panel = createTemporaryObject(panelComponent, testCase,
{ title: "1.20.1", status: "Libraries", fraction: -1 })
verify(panel)
const texts = panel.children.filter(c => c.text !== undefined)
const percent = texts.find(t => t.text === "…")
verify(percent, "без известного итога показывается многоточие")
panel.fraction = 0.426
compare(percent.text, "43%")
panel.fraction = 1
compare(percent.text, "100%")
// Подпись: detail важнее status.
const detailText = texts.find(t => t.text === "Libraries")
verify(detailText)
panel.detail = "brigadier-1.1.8.jar"
compare(detailText.text, "brigadier-1.1.8.jar")
const cancel = signalSpy.createObject(testCase, { target: panel, signalName: "cancelRequested" })
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1)
panel.cancellable = false
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1, "крестик скрыт — отмены нет")
}
// --- LoaderRow -----------------------------------------------------------
function test_loaderRowFollowsBackend() {
const backend = createTemporaryObject(fakeBackendComponent, testCase)
backend.versions = {
"fabric/1.20.1": [ { version: "0.16.10", label: "0.16.10", recommended: false, stable: true },
{ version: "0.16.9", label: "0.16.9", recommended: false, stable: true } ]
}
const row = createTemporaryObject(loaderRowComponent, testCase,
{ backend: backend, loaderKey: "fabric", title: "Fabric Loader" })
verify(row)
const changed = signalSpy.createObject(testCase, { target: row, signalName: "changed" })
// Без версии игры галочка недоступна, список пуст.
compare(row.entries.length, 0)
row.gameVersion = "1.20.1"
compare(row.entries.length, 0, "пока галочка снята — версии не запрашиваются")
// Сборка уже с Fabric: выбранная версия сохраняется, запрос обновления уходит.
row.applyBuild("fabric", "0.16.9")
verify(row.checked)
compare(row.selectedVersion, "0.16.9")
compare(row.entries.length, 2)
verify(backend.refreshCalls.indexOf("fabric/1.20.1") >= 0)
compare(changed.count, 0, "применение сохранённой сборки не считается правкой")
// Каталог обновился и выбранной версии в нём больше нет — берётся первая.
backend.versions = { "fabric/1.20.1": [ { version: "0.17.0", label: "0.17.0", recommended: true, stable: true } ] }
backend.loaderVersionsChanged("fabric", "1.20.1")
compare(row.selectedVersion, "0.17.0")
compare(changed.count, 1)
// Чужой лоадер в сборке — галочка снимается.
row.applyBuild("forge", "47.4.0")
verify(!row.checked)
compare(row.selectedVersion, "")
compare(row.entries.length, 0)
// Смена версии игры сбрасывает выбор.
row.applyBuild("fabric", "0.17.0")
row.gameVersion = "1.19.4"
compare(row.selectedVersion, "")
}
Component { id: signalSpy; SignalSpy {} }
}
+119
View File
@@ -0,0 +1,119 @@
import QtQuick
import QtTest
import BiLauncher
// Диалоги с настоящим LauncherBackend. Данные для них подготавливает tst_qml.cpp:
// кеш манифеста версий (1.20.1, 25w33a, b1.7.3), установленная 1.20.1,
// кеш каталога сезонных сборок с одной сборкой. Сеть закрыта FakeInternet.
TestCase {
id: testCase
name: "Dialogs"
width: 1280
height: 720
visible: true
when: windowShown
LauncherBackend { id: launcherBackend }
Component { id: versionPickerComponent; VersionPickerDialog { backend: launcherBackend } }
Component { id: javaPickerComponent; JavaPickerDialog { backend: launcherBackend } }
Component { id: buildsComponent; BuildsDialog { backend: launcherBackend } }
Component { id: seasonalComponent; SeasonalBuildsDialog { backend: launcherBackend } }
Component { id: signalSpy; SignalSpy {} }
function openAndWait(dialog) {
tryVerify(() => dialog.opened, 3000, "диалог не открылся")
}
function test_versionPickerFiltersCatalog() {
const picker = createTemporaryObject(versionPickerComponent, testCase)
verify(picker)
tryVerify(() => launcherBackend.versionCatalog.length >= 3)
picker.openFor("1.20.1")
openAndWait(picker)
compare(picker.category, "release")
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "1.20.1")
verify(picker.visibleEntries[0].installed)
compare(picker.indexOfSelected(), 0)
picker.category = "snapshot"
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "25w33a")
compare(picker.categoryOf("b1.7.3"), "old_beta")
compare(picker.categoryOf("no-such"), "release")
picker.category = "release"
picker.filterText = "1.19"
compare(picker.visibleEntries.length, 0)
picker.filterText = ""
picker.installedOnly = true
compare(picker.visibleEntries.length, 1)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "versionChosen" })
picker.selectedId = "1.20.1"
picker.acceptSelection()
compare(chosen.count, 1)
compare(chosen.signalArguments[0][0], "1.20.1")
tryVerify(() => !picker.visible)
}
function test_javaPickerOpensWithRequirement() {
const picker = createTemporaryObject(javaPickerComponent, testCase)
verify(picker)
picker.openFor("", 17)
openAndWait(picker)
compare(picker.requiredMajor, 17)
compare(picker.category, "java")
compare(picker.selectedEntry, null)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "runtimeChosen" })
picker.acceptSelection() // ничего не выбрано — ничего не происходит
compare(chosen.count, 0)
picker.close()
}
function test_buildsDialogEditsBackend() {
const dialog = createTemporaryObject(buildsComponent, testCase)
verify(dialog)
const before = launcherBackend.customBuildNames.length
const name = dialog.newBuildName()
verify(name.length > 0)
verify(launcherBackend.customBuildNames.indexOf(name) === -1)
launcherBackend.addCustomBuild(name, "", "1.20.1")
tryVerify(() => !launcherBackend.busy, 20000)
compare(launcherBackend.customBuildNames.length, before + 1)
dialog.open()
openAndWait(dialog)
dialog.selectBuild(before)
compare(dialog.editIndex, before)
dialog.commit({ serverUrl: "play.example.com" })
compare(launcherBackend.customBuildAt(before).serverUrl, "play.example.com")
verify(dialog.newBuildName() !== name)
dialog.close()
}
function test_seasonalDialogShowsCatalog() {
const dialog = createTemporaryObject(seasonalComponent, testCase)
verify(dialog)
dialog.openCatalog()
openAndWait(dialog)
tryVerify(() => launcherBackend.seasonalCatalog.length === 1)
const entry = launcherBackend.seasonalCatalog[0]
compare(entry.id, "season-qml")
compare(dialog.cellText(entry, "loaderLabel"), entry.loaderTitle + " 0.16.10")
compare(dialog.formatMb(3 * 1024 * 1024), "3.0")
compare(dialog.formatMb(0), "")
dialog.close()
}
function test_languageSwitchUpdatesTexts() {
const en = Loc.t.app.tooltip.openMods
launcherBackend.updateSettings({ language: "ru" })
verify(Loc.t.app.tooltip.openMods !== en)
launcherBackend.updateSettings({ language: "en" })
compare(Loc.t.app.tooltip.openMods, en)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Qt Quick Test для QML-компонентов лаунчера.
//
// Модуль BiLauncher у приложения вшит в исполняемый файл и плагина не имеет,
// поэтому qmltestrunner его не импортирует. Здесь тот же модуль собран
// статической библиотекой (bilauncher_testlib) и подключён через
// Q_IMPORT_QML_PLUGIN — тесты пишут обычный «import BiLauncher».
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include <QDateTime>
#include <QDir>
#include <QJsonArray>
#include <QQmlEngine>
#include <QQuickStyle>
#include <QtQml/qqmlextensionplugin.h>
#include <QtQuickTest/quicktest.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
class Setup : public QObject
{
Q_OBJECT
public slots:
void applicationAvailable()
{
TestEnv::finishSetup();
QQuickStyle::setStyle(QStringLiteral("Basic"));
seedData();
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
}
void qmlEngineAvailable(QQmlEngine *engine)
{
engine->addImportPath(QStringLiteral("qrc:/qt/qml"));
}
private:
// Данные, на которых работают диалоги (см. tst_dialogs.qml).
static void seedData()
{
const QString game = TestEnv::freshDir(QStringLiteral("game"));
const QString now = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
const auto version = [](const QString &id, const QString &type, const QString &time) {
return QJsonObject{{"id", id}, {"type", type}, {"releaseTime", time}, {"sha1", "00"},
{"url", "https://piston-meta.mojang.com/v1/packages/x/" + id + ".json"}};
};
Fixtures::writeJson(LauncherPaths::versionManifestFile(), QJsonObject{
{"fetchedAt", now},
{"versions", QJsonArray{version("25w33a", "snapshot", "2025-08-13T12:00:00Z"),
version("1.20.1", "release", "2023-06-12T13:25:51Z"),
version("b1.7.3", "old_beta", "2011-07-08T00:00:00Z")}}});
Fixtures::write(game + "/versions/1.20.1/1.20.1.json",
"{\"id\":\"1.20.1\",\"mainClass\":\"net.minecraft.client.main.Main\"}");
const QJsonObject archive{{"url", "https://launcher-builds.galeonworx.com/packs/q.zip"},
{"size", 3 * 1024 * 1024}, {"sha256", QString(64, u'a')}};
const QJsonObject build{{"id", "season-qml"}, {"name", "QML Season"}, {"revision", 1},
{"minecraftVersion", "1.20.1"}, {"loader", "fabric"},
{"loaderVersion", "0.16.10"}, {"seasonStart", "2026-01-01"},
{"archive", archive}};
Fixtures::writeJson(LauncherPaths::seasonalCatalogFile(), QJsonObject{
{"formatVersion", 1}, {"fetchedAt", now},
{"baseUrl", "https://launcher-builds.galeonworx.com"}, {"builds", QJsonArray{build}}});
}
FakeInternet m_net;
};
int main(int argc, char **argv)
{
TestEnv::prepareSandbox();
QTEST_SET_MAIN_SOURCE_PATH
Setup setup;
return quick_test_main_with_setup(argc, argv, "bilauncher_qml", QUICK_TEST_SOURCE_DIR, &setup);
}
#include "tst_qml.moc"
+109
View File
@@ -0,0 +1,109 @@
// Дымовой тест главного окна: Main.qml поднимается так же, как в main.cpp,
// без единого предупреждения QML, и ни одного запроса в настоящую сеть.
// Ловит то, что не видно компилятору: опечатки в именах свойств и ключах Loc.t,
// сломанные биндинги, несуществующие картинки в ресурсах.
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QtQml/qqmlextensionplugin.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
namespace {
QStringList g_problems;
QtMessageHandler g_previous = nullptr;
void collect(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
// Предупреждения QML приходят с файлом qrc:/… или с категорией qt.qml.*.
const QString file = QString::fromUtf8(context.file ? context.file : "");
const QString category = QString::fromUtf8(context.category ? context.category : "");
const bool fromQml = file.startsWith(QLatin1String("qrc:")) || file.endsWith(QLatin1String(".qml"))
|| category.startsWith(QLatin1String("qt.qml"))
|| message.contains(QLatin1String(".qml:"));
if (type != QtDebugMsg && type != QtInfoMsg && fromQml)
g_problems << message;
if (g_previous)
g_previous(type, context, message);
}
} // namespace
class TestQmlSmoke : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase()
{
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
const QString game = TestEnv::freshDir(QStringLiteral("game"));
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
QQuickStyle::setStyle(QStringLiteral("Basic"));
g_previous = qInstallMessageHandler(collect);
}
void cleanupTestCase() { qInstallMessageHandler(g_previous); }
void init() { g_problems.clear(); }
void mainWindowLoadsCleanly()
{
// Один движок на весь тест, как в приложении: Localization держит указатель
// на JS-движок, создавший синглтон, и второй QQmlApplicationEngine после
// уничтожения первого оставил бы его висячим.
QQmlApplicationEngine engine;
QStringList warnings;
connect(&engine, &QQmlEngine::warnings, this, [&](const QList<QQmlError> &list) {
for (const QQmlError &e : list)
warnings << e.toString();
});
engine.loadFromModule("BiLauncher", "Main");
QCOMPARE(engine.rootObjects().size(), 1);
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
QVERIFY(window);
QVERIFY(QTest::qWaitForWindowExposed(window));
// Даём отработать отложенным таймерам, загрузке каталогов и анимациям.
QTest::qWait(2500);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Смена языка на лету перестраивает Loc.t — биндинги не должны ломаться.
const QString titleEn = window->title();
Localization::instance().setLanguage(QStringLiteral("ru"));
QTest::qWait(500);
Localization::instance().setLanguage(QStringLiteral("en"));
QTest::qWait(300);
QCOMPARE(window->title(), titleEn);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Наружу — только через FakeInternet, и только на известные адреса лаунчера.
static const QStringList known{"mojang.com", "galeonworx.com", "adoptium.net", "fabricmc.net",
"quiltmc.org", "minecraftforge.net", "neoforged.net"};
for (const QString &url : m_net.requestedUrls()) {
const QString host = QUrl(url).host();
const bool ok = std::any_of(known.cbegin(), known.cend(),
[&](const QString &domain) { return host.endsWith(domain); });
QVERIFY2(ok, qPrintable("неожиданный запрос: " + url));
}
}
};
BILAUNCHER_GUI_TEST_MAIN(TestQmlSmoke)
#include "tst_qml_smoke.moc"