Fix playback, API races, tray, MPRIS, and search UX; add tests

- Correct history/resume next-previous context and list index resync after reload
- Abort stale RadioBrowser requests and suppress error banners when cache satisfies loads
- Reset local search filter on page changes and debounce compact-mode API search
- Move system tray to C++ (QSystemTrayIcon) for reliable Wayland context menus
- Harden MPRIS registration and metadata updates for Plasma media widgets
- Add playback and API race unit tests
- Drop unused KF6 Kirigami, I18n, and CoreAddons build dependencies
- Update packaging docs, CI, and PKGBUILD to match Qt-only requirements
This commit is contained in:
Sebastian Palencsar
2026-06-20 14:06:48 +02:00
parent 4718810d24
commit 1c736b97a6
20 changed files with 1007 additions and 155 deletions

48
tests/CMakeLists.txt Normal file
View File

@@ -0,0 +1,48 @@
find_package(Qt6 REQUIRED COMPONENTS Test)
set(TEST_SOURCES
radiobackend_playback_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobackend.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiostation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobrowser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/bearplayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/coverartfetcher.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/icyreader.cpp
)
add_executable(radiobackend_playback_test ${TEST_SOURCES})
target_include_directories(radiobackend_playback_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../src
)
target_link_libraries(radiobackend_playback_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
Qt6::Widgets
Qt6::Multimedia
)
add_test(NAME radiobackend_playback_test COMMAND radiobackend_playback_test)
set(RACE_TEST_SOURCES
radiobrowser_race_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobrowser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiostation.cpp
)
add_executable(radiobrowser_race_test ${RACE_TEST_SOURCES})
target_include_directories(radiobrowser_race_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../src
)
target_link_libraries(radiobrowser_race_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
Qt6::Widgets
)
add_test(NAME radiobrowser_race_test COMMAND radiobrowser_race_test)

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest>
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTemporaryDir>
#include <QCryptographicHash>
#include <QEventLoop>
#include <QTimer>
#include "radiobackend.h"
class RadioBackendPlaybackTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void init();
void history_next_navigates_older_entries();
void resume_disables_list_navigation();
void station_list_next_uses_filtered_index();
void history_next_does_not_jump_to_station_list();
void cached_load_keeps_error_clear_after_network_failure();
void list_reload_resyncs_index_for_playing_station();
void list_next_disabled_when_playing_station_not_in_new_list();
};
namespace {
QTemporaryDir *s_tempHome = nullptr;
QString cachePathForEndpoint(const QString &endpoint)
{
const QString cacheDir = QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
return cacheDir + QStringLiteral("/") + hash + QStringLiteral(".json");
}
void writeStationCache(const QString &endpoint, const QString &name,
const QString &url = QString(), const QString &uuid = QString())
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QJsonObject station;
station.insert(QStringLiteral("name"), name);
station.insert(QStringLiteral("url_resolved"),
url.isEmpty() ? QStringLiteral("http://example.com/") + name : url);
station.insert(QStringLiteral("country"), QStringLiteral("Germany"));
station.insert(QStringLiteral("stationuuid"), uuid.isEmpty() ? name : uuid);
QJsonArray array;
array.append(station);
QFile file(cachePathForEndpoint(endpoint));
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
file.write(QJsonDocument(array).toJson());
file.close();
}
void writeStationListCache(const QString &endpoint, const QJsonArray &stations)
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QFile file(cachePathForEndpoint(endpoint));
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
file.write(QJsonDocument(stations).toJson());
file.close();
}
int visibleIndexForUrl(RadioBackend &backend, const QString &url)
{
const QList<QObject*> stations = backend.stations();
for (int i = 0; i < stations.size(); ++i) {
QObject *station = stations[i];
const QString resolved = station->property("urlResolved").toString();
if (resolved == url) {
return i;
}
}
return -1;
}
}
void RadioBackendPlaybackTest::initTestCase()
{
s_tempHome = new QTemporaryDir();
QVERIFY(s_tempHome->isValid());
qputenv("HOME", s_tempHome->path().toUtf8());
const QString configDir = QDir::homePath() + QStringLiteral("/.config/bearwave");
QDir().mkpath(configDir);
QJsonObject state;
state.insert(QStringLiteral("lastStationName"), QStringLiteral("Station A"));
state.insert(QStringLiteral("lastStationUrl"), QStringLiteral("http://example.com/a"));
state.insert(QStringLiteral("volume"), 0.5);
QJsonArray recent;
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station A")},
{QStringLiteral("uuid"), QStringLiteral("uuid-a")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/a")}
});
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station B")},
{QStringLiteral("uuid"), QStringLiteral("uuid-b")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/b")}
});
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station C")},
{QStringLiteral("uuid"), QStringLiteral("uuid-c")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/c")}
});
state.insert(QStringLiteral("recentStations"), recent);
QFile file(configDir + QStringLiteral("/state.json"));
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(QJsonDocument(state).toJson());
file.close();
}
void RadioBackendPlaybackTest::init()
{
initTestCase();
}
void RadioBackendPlaybackTest::history_next_navigates_older_entries()
{
RadioBackend backend;
QVERIFY(backend.playRecentByUuid(QStringLiteral("uuid-b"), QString()));
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/b"));
QVERIFY(backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(backend.hasPreviousStation());
}
void RadioBackendPlaybackTest::resume_disables_list_navigation()
{
RadioBackend backend;
backend.resumeLastStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(!backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
}
void RadioBackendPlaybackTest::station_list_next_uses_filtered_index()
{
RadioBackend backend;
backend.addManualStation(QStringLiteral("Station One"), QStringLiteral("http://example.com/one"), QStringLiteral("DE"));
backend.addManualStation(QStringLiteral("Station Two"), QStringLiteral("http://example.com/two"), QStringLiteral("DE"));
backend.playStation(0);
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/two"));
QVERIFY(backend.hasNextStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/one"));
}
void RadioBackendPlaybackTest::history_next_does_not_jump_to_station_list()
{
RadioBackend backend;
// Loaded station list present, but playback started from history (not playStation()).
backend.addManualStation(QStringLiteral("Station X"), QStringLiteral("http://example.com/x"), QStringLiteral("DE"));
backend.addManualStation(QStringLiteral("Station Y"), QStringLiteral("http://example.com/y"), QStringLiteral("DE"));
QVERIFY(backend.playRecentByUuid(QStringLiteral("uuid-c"), QString()));
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/c"));
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(backend.currentStationUrl() != QStringLiteral("http://example.com/x"));
QVERIFY(backend.currentStationUrl() != QStringLiteral("http://example.com/y"));
}
void RadioBackendPlaybackTest::cached_load_keeps_error_clear_after_network_failure()
{
writeStationCache(QStringLiteral("/stations/topvote/100"), QStringLiteral("Cached Top Station"));
RadioBackend backend;
QVERIFY(QFile::exists(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache")));
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() > 0, 2000);
QCOMPARE(backend.lastError(), QString());
QEventLoop loop;
QTimer::singleShot(11000, &loop, &QEventLoop::quit);
loop.exec();
QCOMPARE(backend.lastError(), QString());
QVERIFY(backend.stations().size() > 0);
}
void RadioBackendPlaybackTest::list_reload_resyncs_index_for_playing_station()
{
const QString topEndpoint = QStringLiteral("/stations/topvote/100");
const QString deEndpoint = QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true");
const QString sharedUrl = QStringLiteral("http://example.com/shared");
QJsonArray topStations;
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Alpha Top")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/alpha")},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-alpha")}
});
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Shared Station")},
{QStringLiteral("url_resolved"), sharedUrl},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-shared")}
});
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Zulu Top")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/zulu")},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-zulu")}
});
writeStationListCache(topEndpoint, topStations);
QJsonArray deStations;
deStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("DE Only")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/deonly")},
{QStringLiteral("country"), QStringLiteral("Germany")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-deonly")}
});
deStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Shared Station")},
{QStringLiteral("url_resolved"), sharedUrl},
{QStringLiteral("country"), QStringLiteral("Germany")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-shared")}
});
writeStationListCache(deEndpoint, deStations);
RadioBackend backend;
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 3, 2000);
const int sharedTopIndex = visibleIndexForUrl(backend, sharedUrl);
QVERIFY(sharedTopIndex >= 0);
backend.playStation(sharedTopIndex);
QCOMPARE(backend.currentStationUrl(), sharedUrl);
QVERIFY(backend.hasNextStation());
backend.loadGermanStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 2, 2000);
QCOMPARE(backend.currentStationUrl(), sharedUrl);
QVERIFY(!backend.hasNextStation());
QVERIFY(backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), sharedUrl);
backend.playPreviousStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/deonly"));
}
void RadioBackendPlaybackTest::list_next_disabled_when_playing_station_not_in_new_list()
{
const QString topEndpoint = QStringLiteral("/stations/topvote/100");
const QString deEndpoint = QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true");
const QString topUrl = QStringLiteral("http://example.com/toponly");
writeStationCache(topEndpoint, QStringLiteral("Top Only"), topUrl, QStringLiteral("uuid-top"));
writeStationCache(deEndpoint, QStringLiteral("DE Only"), QStringLiteral("http://example.com/deonly"),
QStringLiteral("uuid-de"));
RadioBackend backend;
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 1, 2000);
backend.playStation(0);
QCOMPARE(backend.currentStationUrl(), topUrl);
QVERIFY(!backend.hasNextStation());
backend.loadGermanStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 1, 2000);
QCOMPARE(backend.currentStationUrl(), topUrl);
QVERIFY(!backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), topUrl);
}
QTEST_MAIN(RadioBackendPlaybackTest)
#include "radiobackend_playback_test.moc"

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest>
#include <QApplication>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QEventLoop>
#include <QTimer>
#include "radiobrowser.h"
class RadioBrowserRaceTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void latest_request_wins_after_rapid_category_switch();
};
namespace {
QTemporaryDir *s_tempHome = nullptr;
QString cachePathForEndpoint(const QString &endpoint)
{
const QString cacheDir = QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
return cacheDir + QStringLiteral("/") + hash + QStringLiteral(".json");
}
void writeStationCache(const QString &endpoint, const QString &name, const QString &country)
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QJsonObject station;
station.insert(QStringLiteral("name"), name);
station.insert(QStringLiteral("url_resolved"), QStringLiteral("http://example.com/") + name);
station.insert(QStringLiteral("country"), country);
station.insert(QStringLiteral("stationuuid"), name);
QJsonArray array;
array.append(station);
QFile file(cachePathForEndpoint(endpoint));
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(QJsonDocument(array).toJson());
file.close();
}
}
void RadioBrowserRaceTest::initTestCase()
{
s_tempHome = new QTemporaryDir();
QVERIFY(s_tempHome->isValid());
qputenv("HOME", s_tempHome->path().toUtf8());
writeStationCache(QStringLiteral("/stations/topvote/5"), QStringLiteral("Top Station"), QStringLiteral("Global"));
writeStationCache(QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true"),
QStringLiteral("DE Station"), QStringLiteral("Germany"));
writeStationCache(QStringLiteral("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true"),
QStringLiteral("NL Station"), QStringLiteral("The Netherlands"));
}
void RadioBrowserRaceTest::latest_request_wins_after_rapid_category_switch()
{
RadioBrowser browser;
QSignalSpy stationsSpy(&browser, &RadioBrowser::stationsLoaded);
browser.getTopStations(5);
browser.getGermanStations();
browser.getDutchStations();
QCOMPARE(stationsSpy.count(), 3);
const auto lastVariant = stationsSpy.at(2).at(0);
const QList<RadioStation*> lastStations = lastVariant.value<QList<RadioStation*>>();
QVERIFY(!lastStations.isEmpty());
QCOMPARE(lastStations.first()->name(), QStringLiteral("NL Station"));
QEventLoop loop;
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
loop.exec();
const auto finalVariant = stationsSpy.takeLast().at(0);
const QList<RadioStation*> finalStations = finalVariant.value<QList<RadioStation*>>();
QVERIFY(!finalStations.isEmpty());
const QString finalName = finalStations.first()->name();
const QString finalCountry = finalStations.first()->country();
QVERIFY(finalName != QStringLiteral("Top Station"));
QVERIFY(finalName != QStringLiteral("DE Station"));
QVERIFY(finalCountry.contains(QStringLiteral("Netherlands"), Qt::CaseInsensitive)
|| finalName == QStringLiteral("NL Station"));
}
QTEST_MAIN(RadioBrowserRaceTest)
#include "radiobrowser_race_test.moc"