mudlet/test/functional_tests/TMediaLoopTest.cpp
Vadim Peretokin 15e52faef9
infrastructure: a media player no longer announces its own destruction (#9746)
#### Brief overview of PR changes/additions
- `TMediaPlayer::~TMediaPlayer()` blocks its `QMediaPlayer`'s signals
before the `stop()`/`setSource(QUrl())` that unloads the media, so no
handler is called with a player whose members are going away. The unload
itself is unchanged, and Qt still emits `destroyed()`.
- New `TMediaLoopTest::test_destroyingAPlayerAnnouncesNothing()`, with a
control unload on a player that is not being destroyed so it cannot pass
vacuously.
- `test_continuingToTheNextPassClearsTheEarlierAnnouncement()` declares
its flag ahead of the player, so a lambda connected to that player
always has live stack to write to.

#### Motivation for adding to Mudlet
Emitting signals from a destructor is a landmine for any connected code:
today TMedia's own handlers survive it only because each one locks an
already-expired `weak_ptr`, and the test suite, which does not, aborted.

#### Other info (issues closed, discussion etc)
Closes #9740 "TMediaLoopTest aborts under AddressSanitizer with
stack-use-after-scope".

Reproduced and verified on Linux with a clang ASan Debug build
(`USE_SANITIZER=address`): pre-fix `TMediaLoopTest` aborts with
`stack-use-after-scope` in `~TMediaPlayer` ->
`QMediaPlayer::setSource()`, post-fix the suite is clean. The new test
fails without the destructor change (counts 1 announcement) and passes
with it. GCC does not poison per-variable within a scope, so the abort
only shows up under clang/AppleClang.

**Test case:** `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
-DCMAKE_CXX_COMPILER=clang++` then `cmake --build build --target
TMediaLoopTest && ctest --test-dir build -R TMediaLoopTest` - passes
instead of `***Exception`.

Assisted-by: Claude:claude-opus-5
2026-08-10 22:17:53 +02:00

880 lines
42 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Mudlet Developers *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include <QAudioOutput>
#include <QDeadlineTimer>
#include <QMediaPlayer>
#include <QTemporaryDir>
#include <QtTest/QtTest>
#include <chrono>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TMedia.h"
#include "TMediaData.h"
#include "TelnetServerStub.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
#include "utils.h"
extern void qInitResources_mudlet();
extern void qInitResources_qm();
extern void qInitResources_additional_splash_screens();
extern void qInitResources_mudlet_fonts_common();
extern void qInitResources_mudlet_fonts_posix();
void initializeQRCResourcesForMediaLoop();
using namespace std::chrono_literals;
// A skip is how these tests stay honest on a backend that cannot stage what they need. It is
// also how the whole file could go green everywhere and mean nothing, if a CI image lost its
// codecs or swapped its default backend - macOS already skips most of them by design, so a
// second platform quietly joining it would look no different. Runners known to carry a backend
// that can demonstrate everything here set MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK, which turns
// every capability skip into a failure and makes that loss a red build instead of silence.
#define SKIP_OR_FAIL_WITHOUT(reason) \
do { \
const QString incapable = (reason); \
if (!incapable.isEmpty()) { \
if (qEnvironmentVariableIsSet("MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK")) { \
QFAIL(qPrintable(qsl("MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK is set for this runner, so a backend that cannot do this is a failure and " \
"not a skip: %1") \
.arg(incapable))); \
} \
QSKIP(qPrintable(incapable)); \
} \
} while (false)
/*
* Regression guard for "Client.Media loops=-1 plays once" (issue #9566): the deferred media
* source release in TMedia, and the generation counters documented on TMediaPlayer that decide
* whether it still applies by the time its turn comes. The obligations that follow:
*
* - a looping track must survive the stop/restart cycle (the #9566 bug itself);
* - a finite loops=N track must reach every pass, which goes through the playlist
* branch of the same handler;
* - a track that genuinely finishes, or is stopped outright, must still release
* its source, or the resource release #9237 added is lost;
* - a source the backend cannot decode must release itself off the error signal, since
* the player was already stopped and no playback state change follows;
* - a player re-sourced during the deferred turn, by a different track or by a
* continue=false restart of the same one, must keep the source it was given;
* - each of those endings must raise sysMediaFinished exactly once, since releasing the
* source alone leaves a script chaining its next track off that event waiting forever,
* and announcing twice re-enters any handler that stops the media it was told about.
*
* Which of those a backend can demonstrate varies, so probeBackend() measures one up front and
* each test skips with what it found. CMakeLists.txt pins QT_MEDIA_BACKEND on the platforms
* where main.cpp does, and leaves it to Qt elsewhere, exactly as the shipped application does -
* so a skip reflects what users actually get.
*/
class TMediaLoopTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mHostname = "Test-Media-Loop";
const QString mPort = "4012";
const QString mLocalhost = "localhost";
// Length of the generated clip. Long enough that "still playing" cannot be an
// artefact of start-up latency, short enough to loop several times quickly.
static constexpr int clipMs = 400;
QTemporaryDir mProbeDir;
// Set when the backend never reaches PlayingState, so nothing below can even be started.
QString mCannotStartReason;
// Set when the backend starts a clip but never decodes it through to EndOfMedia.
QString mCannotPlayReason;
// Set when the backend ends a track with EndOfMedia before StoppedState.
QString mWrongOrderReason;
// Set when the backend starts playing synchronously, so a player that has just been
// re-sourced can never be mistaken for a stopped one - which is the whole race the
// claim counter exists to settle.
QString mSynchronousStartReason;
// Set when the backend does not report an undecodable file as an error.
QString mNoLoadErrorReason;
private slots:
void initTestCase()
{
initializeQRCResourcesForMediaLoop();
probeBackend();
}
void init()
{
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, mPort.toUShort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mHostname);
}
// A looping track must still be playing well after its first pass would have
// ended. Before the fix the first StoppedState cleared the source, EndOfMedia
// was never delivered and the player dropped out of the playing set for good.
void test_loopingTrackKeepsPlayingPastFirstPass()
{
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
SKIP_OR_FAIL_WITHOUT(mWrongOrderReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
const QString fileName = writeClip(qsl("loop.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(data);
QVERIFY2(waitForPlaying(media, fileName), "The looping track never started playing.");
// Span several passes so a single missed restart cannot pass by luck, and land off a
// clip boundary: StoppedState and the EndOfMedia that restarts the loop are separate
// signals, and in the window between them a healthy player reads as not playing.
QTest::qWait(clipMs * 4 + clipMs / 2);
QVERIFY2(waitForPlaying(media, fileName, 2s), "A loops=-1 track stopped after its first pass - the StoppedState cleanup suppressed EndOfMedia and the loop never restarted.");
}
// Every pass of a finite loops=N track after the first comes from the playlist branch of
// the same EndOfMedia handler, which the indefinite-loop test never reaches. Its final
// pass is also the one place a continuation ends and the deferred cleanup must take over.
// Both hold whichever way round the backend emits EndOfMedia and StoppedState, so unlike
// the loop test this one needs no mWrongOrderReason gate.
void test_finiteLoopsReachEveryPass()
{
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
const QString fileName = writeClip(qsl("finite.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(3);
media->playMedia(data);
QVERIFY2(waitForPlaying(media, fileName), "The finite-loop track never started playing.");
// Into the second pass, which only happens if the playlist advanced.
QTest::qWait(clipMs + clipMs / 2);
QVERIFY2(waitForPlaying(media, fileName, 2s), "A loops=3 track stopped after its first pass - the playlist never advanced to the next entry.");
// ...and the last pass must still hand back to the cleanup rather than loop forever.
const bool cleanedUp = QTest::qWaitFor(
[&]() {
return !playing(media, fileName) && media->playersHoldingSource() == 0;
},
QDeadlineTimer(10s));
QVERIFY2(cleanedUp, "A loops=3 track never finished and released its source - the deferred cleanup did not take over from the last pass.");
}
// Staged rather than played: only a backend that delivers EndOfMedia before StoppedState
// announces a pass from inside continuePlaying()'s setSource(), and no runner this suite has
// does. sourceChanged stands in for that announcement, being emitted from inside the same
// setSource() call.
void test_continuingToTheNextPassClearsTheEarlierAnnouncement()
{
const QString path = qsl("%1/pass.wav").arg(mProbeDir.path());
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(wavBytes());
file.close();
TMediaData data{};
// Declared ahead of the player: should a player ever emit while being destroyed again,
// the lambda below has to have somewhere live to write to.
bool announcedFromInsideSetSource = false;
TMediaPlayer player(nullptr, data);
QVERIFY(player.isInitialized());
connect(player.mediaPlayer(), &QMediaPlayer::sourceChanged, player.mediaPlayer(), [&](const QUrl&) {
player.noteEndAnnounced();
announcedFromInsideSetSource = true;
});
player.continuePlaying(QUrl::fromLocalFile(path));
player.mediaPlayer()->stop();
QVERIFY2(announcedFromInsideSetSource, "setSource() emitted nothing synchronously, so the ordering this test is about was never staged.");
QVERIFY2(!player.endAnnounced(), "The new pass started already counted as announced, so its own ending would be swallowed as a duplicate.");
}
// Destroying a player unloads whatever it still holds, and nothing may hear that: a handler
// that does would be reading a TMediaPlayer whose members are about to go. The handlers
// TMedia installs decline anyway, each by way of a weak_ptr already expired by then, so
// what this holds to is that no future one has to. Staged rather than played, since the
// unload needs a source and not a backend that can decode it (#9740).
void test_destroyingAPlayerAnnouncesNothing()
{
const QString path = qsl("%1/teardown.wav").arg(mProbeDir.path());
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(wavBytes());
file.close();
int announcementsWhileBeingDestroyed = 0;
bool beingDestroyed = false;
const auto count = [&]() {
if (beingDestroyed) {
++announcementsWhileBeingDestroyed;
}
};
TMediaData data{};
{
TMediaPlayer player(nullptr, data);
QVERIFY(player.isInitialized());
connect(player.mediaPlayer(), &QMediaPlayer::sourceChanged, player.mediaPlayer(), count);
connect(player.mediaPlayer(), &QMediaPlayer::playbackStateChanged, player.mediaPlayer(), count);
player.continuePlaying(QUrl::fromLocalFile(path));
QVERIFY2(!player.mediaPlayer()->source().isEmpty(), "The player holds no source, so its destructor has nothing to unload and this test proves nothing.");
beingDestroyed = true;
}
QVERIFY2(announcementsWhileBeingDestroyed == 0, "A player announced its own teardown, so every handler connected to it ran against a player being deleted.");
// The same unload on a player that is not being destroyed, to keep the assertion above
// from passing on a Qt that has stopped announcing unloads at all.
int announcementsFromAnUnblockedUnload = 0;
const auto countUnblocked = [&]() {
++announcementsFromAnUnblockedUnload;
};
QMediaPlayer unblocked;
connect(&unblocked, &QMediaPlayer::sourceChanged, &unblocked, countUnblocked);
connect(&unblocked, &QMediaPlayer::playbackStateChanged, &unblocked, countUnblocked);
unblocked.setSource(QUrl::fromLocalFile(path));
announcementsFromAnUnblockedUnload = 0;
unblocked.stop();
unblocked.setSource(QUrl());
QVERIFY2(announcementsFromAnUnblockedUnload > 0, "An unload announced nothing even unblocked, so the assertion above passes without the destructor having to block anything.");
}
// The deferred cleanup must still fire for a genuinely finished track, otherwise the
// media source release added by #9237 is lost. Releasing the source is what this asserts
// on because playingMedia() has already dropped the player by the time the cleanup runs.
void test_oneShotTrackIsCleanedUpWhenItFinishes()
{
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
watchMediaFinished();
const QString fileName = writeClip(qsl("oneshot.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsDefault);
media->playMedia(data);
QVERIFY2(waitForPlaying(media, fileName), "The one-shot track never started playing.");
const bool cleanedUp = QTest::qWaitFor(
[&]() {
return !playing(media, fileName) && media->playersHoldingSource() == 0;
},
QDeadlineTimer(10s));
QVERIFY2(cleanedUp, "A finished one-shot track never released its media source - the deferred cleanup did not run.");
QVERIFY2(waitForMediaFinishedCount(1), "A finished one-shot track raised no single sysMediaFinished - a script chaining its next track off that event would wait forever.");
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'oneshot.wav'")), "sysMediaFinished named the wrong file for a finished one-shot track.");
}
// The same obligation as above, reached by an explicit stop rather than by the clip
// ending. It asks the least of the backend of any test here - only that playback starts -
// so it is the one that still runs on a runner whose backend cannot decode a clip.
//
// Deliberately the weaker waitForPlaying(): on an asynchronous backend that lands the stop
// while the track is still loading, which is a player Qt already considers stopped and so
// one that reports no state change to end its playback. Holding a source for good is
// exactly what that used to cost, so this is the case worth keeping.
void test_stoppedTrackReleasesItsSource()
{
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
watchMediaFinished();
const QString fileName = writeClip(qsl("stopped.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(data);
QVERIFY2(waitForPlaying(media, fileName), "The track never started playing.");
TMediaData stop = clipData(fileName);
media->stopMedia(stop);
// Asserted separately from the release below: "still playing" and "still holding a
// source" are different faults with different causes, and a combined wait cannot say
// which of them a failure is.
const bool stopped = QTest::qWaitFor(
[&]() {
return !playing(media, fileName);
},
QDeadlineTimer(10s));
QVERIFY2(stopped, "A stopped track was still reported as playing - stopMedia() did not take it out of the playing set.");
const bool released = QTest::qWaitFor(
[&]() {
return media->playersHoldingSource() == 0;
},
QDeadlineTimer(10s));
QVERIFY2(released, "A stopped track never released its media source - the deferred release did not run.");
QVERIFY2(waitForMediaFinishedCount(1), "A stopped track raised no single sysMediaFinished - the silent stop this test exists for is only half fixed if the release happens without it.");
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'stopped.wav'")), "sysMediaFinished named the wrong file for a stopped track.");
// Nothing more may be said about it afterwards. The source outlives the event by a
// turn, so the stop, the error handler and a StoppedState report can each still find a
// playback that looks live and announce the same ending over again.
QTest::qWait(clipMs);
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")), "A stopped track raised sysMediaFinished more than once for the same playback.");
}
// Two ways a stop can say something it should not. A bare stopMusic() matches every player
// there is, including pooled ones between tracks that have nothing playing to end; and a
// stop issued from inside a sysMediaFinished handler - the natural place for a script to
// decide it has heard enough - lands on a player still holding the source of the track it
// was just told about, which used to look exactly like one more playback to end. That
// announced again, re-entered the same handler, and recursed until the stack gave out.
void test_stopDoesNotAnnounceWhatIsNotPlaying()
{
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
watchMediaFinished(qsl("stopMusic()"));
const QString fileName = writeClip(qsl("recursion.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(data);
QVERIFY2(waitForPlaying(media, fileName), "The track never started playing.");
TMediaData stop = clipData(fileName);
media->stopMedia(stop);
QVERIFY2(waitForMediaFinishedCount(1), "A stopped track raised no single sysMediaFinished, so the handler that stops it again never ran and the recursion this test guards was never staged.");
// Everything is idle by now, so a stop matching every player has nothing left to end.
TMediaData stopEverything;
stopEverything.setMediaProtocol(TMediaData::MediaProtocolAPI);
media->stopMedia(stopEverything);
QTest::qWait(clipMs);
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")),
"A stop announced a playback that was already over - either the handler's own stopMusic() recursed back through it, or pooled players holding nothing were ended too.");
}
// A source that fails to load reports an error and no playback state change, because a
// player that was already stopped - as every claimSource() on a new or finished player
// leaves it, and as a loop restart or playlist advance finds it - has nothing to change
// from. Without the error being acted on, the track falls silent still holding a source
// nothing will ever release.
void test_unplayableTrackReleasesItsSource()
{
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
SKIP_OR_FAIL_WITHOUT(mNoLoadErrorReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
watchMediaFinished();
const QString fileName = writeUnplayableClip(qsl("broken.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(data);
// Without this the wait below is satisfied at once by a play() that bailed out early,
// and the error path this test exists for is never reached. claimSource() sets the
// source inside playMedia() and the release is deferred, so the count is settled here.
QCOMPARE(media->playersHoldingSource(), 1);
const bool released = QTest::qWaitFor(
[&]() {
return media->playersHoldingSource() == 0;
},
QDeadlineTimer(10s));
QVERIFY2(released, "A track that could not be decoded held on to its media source - the playback error was never acted on.");
QVERIFY2(waitForMediaFinishedCount(1), "A track that could not be decoded raised no single sysMediaFinished - a script chaining its next track off that event would wait forever.");
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'broken.wav'")), "sysMediaFinished named the wrong file for a track that could not be decoded.");
// The error and the StoppedState that can follow it are two reports of one failure.
QTest::qWait(clipMs);
QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")), "A track that could not be decoded raised sysMediaFinished more than once for the same failure.");
}
// A player that is handed to a different track in the same event-loop turn, as
// stopMusic{} followed by playMusic{} in one script does, must keep the new source. The
// pending cleanup belongs to the track that stopped, and on an asynchronously starting
// backend (see mSynchronousStartReason) the player still reads as stopped while it loads.
void test_reusedPlayerKeepsTheTrackThatClaimedIt()
{
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
const QString firstFile = writeClip(qsl("first.wav"));
const QString secondFile = writeClip(qsl("second.wav"));
QVERIFY(!firstFile.isEmpty() && !secondFile.isEmpty());
TMediaData first = clipData(firstFile);
first.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(first);
QVERIFY2(waitForPlaybackStarted(media, firstFile), "The first track never started playing.");
const int playerCount = media->mediaPlayerCount();
TMediaData stopFirst = clipData(firstFile);
media->stopMedia(stopFirst);
TMediaData second = clipData(secondFile);
second.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(second);
QVERIFY2(waitForPlaying(media, secondFile), "The replacement track never started playing.");
// Without this the test passes vacuously on a second player, having never exercised
// the claim the deferred cleanup has to notice.
QCOMPARE(media->mediaPlayerCount(), playerCount);
// Past the turn the stopped track's cleanup was scheduled for.
QTest::qWait(clipMs);
QVERIFY2(waitForPlaying(media, secondFile, 2s), "The replacement track was cut off - the previous track's deferred cleanup cleared the source out from under it.");
}
// continue=false restarts a track by stopping it and re-sourcing the same player inside
// one call. That player is matched rather than newly acquired, so the restart has to
// register the claim itself or the stop it just performed clears the source it just set.
void test_restartedTrackKeepsItsNewSource()
{
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason);
auto* media = startProfileAndGetMedia();
QVERIFY(media);
const QString fileName = writeClip(qsl("restart.wav"));
QVERIFY(!fileName.isEmpty());
TMediaData data = clipData(fileName);
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
media->playMedia(data);
QVERIFY2(waitForPlaybackStarted(media, fileName), "The track never started playing.");
TMediaData restart = clipData(fileName);
restart.setMediaLoops(TMediaData::MediaLoopsRepeat);
restart.setMediaContinue(TMediaData::MediaContinueRestart);
media->playMedia(restart);
// Past the turn the stop inside that restart scheduled its cleanup for.
QTest::qWait(clipMs);
QVERIFY2(waitForPlaying(media, fileName, 2s), "A restarted track was cut off - the cleanup deferred by its own stop cleared the source it had just been given.");
}
void cleanup()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
deleteProfileDirectory(mHostname);
delete mudlet::self();
}
private:
TMedia* startProfileAndGetMedia()
{
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
if (!host) {
QTest::qFail("No active host available for the test.", __FILE__, __LINE__);
return nullptr;
}
auto* media = host->mpMedia.data();
if (!media) {
QTest::qFail("Host has no TMedia instance.", __FILE__, __LINE__);
return nullptr;
}
mpHost = host;
return media;
}
// sysMediaFinished is half of what the fixes here are for - a track that fails to load and
// one stopped while it is still loading each used to end in silence, with a script chaining
// its next track off that event waiting forever. Releasing the source, which is all the
// tests otherwise assert on, happens either way, so nothing here would notice the event
// going missing. Counted rather than merely seen: announcing the same ended playback more
// than once is its own bug, and one of them recursed until the stack gave out.
void watchMediaFinished(const QString& extraHandlerBody = QString())
{
if (!mpHost) {
return;
}
mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("mediaFinishedCount = 0\n"
"mediaFinishedNames = {}\n"
"registerAnonymousEventHandler('sysMediaFinished', function(_, fileName)\n"
" mediaFinishedCount = mediaFinishedCount + 1\n"
" mediaFinishedNames[#mediaFinishedNames + 1] = fileName\n"
" %1\n"
"end)\n")
.arg(extraHandlerBody));
}
// Runs a Lua assertion against what watchMediaFinished() recorded; compileAndExecuteScript()
// reports a raised error as false, so a failed assert() comes back here as one.
bool mediaFinishedHolds(const QString& luaCondition) const { return mpHost && mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("assert(%1)").arg(luaCondition)); }
bool waitForMediaFinishedCount(int count, std::chrono::milliseconds timeout = 10s) const
{
return QTest::qWaitFor(
[&]() {
return mediaFinishedHolds(qsl("mediaFinishedCount == %1").arg(count));
},
QDeadlineTimer(timeout));
}
// Records what this backend is and is not able to demonstrate; each reason string set below
// spells out what that costs the tests reading it. Probed once, because it has to wait out a
// whole clip and the suite gives each test executable one wall-clock budget for all of its
// slots. test_stoppedTrackReleasesItsSource needs no capability and always runs.
void probeBackend()
{
if (!mProbeDir.isValid()) {
// Not a backend capability, so not a skip: the harness cannot do its own setup.
QFAIL("Could not create a temporary directory for the backend probe.");
}
const QString path = qsl("%1/probe.wav").arg(mProbeDir.path());
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
QFAIL("Could not write the backend probe clip.");
}
file.write(wavBytes());
file.close();
QMediaPlayer probe;
auto* output = new QAudioOutput(&probe);
output->setMuted(true);
probe.setAudioOutput(output);
bool sawEndOfMedia = false;
bool stoppedCameFirst = false;
bool sawPlaying = false;
connect(&probe, &QMediaPlayer::mediaStatusChanged, this, [&](QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::EndOfMedia) {
sawEndOfMedia = true;
}
});
connect(&probe, &QMediaPlayer::playbackStateChanged, this, [&](QMediaPlayer::PlaybackState state) {
if (state == QMediaPlayer::PlayingState) {
sawPlaying = true;
}
if (state == QMediaPlayer::StoppedState && !sawEndOfMedia) {
stoppedCameFirst = true;
}
});
probe.setSource(QUrl::fromLocalFile(path));
probe.play();
// Whether a player that has just been handed a source still reads as stopped is
// the whole reason the deferred cleanup needs to check who owns the player.
const bool startsSynchronously = probe.playbackState() == QMediaPlayer::PlayingState;
const bool finished = QTest::qWaitFor(
[&]() {
return sawEndOfMedia;
},
QDeadlineTimer(10s));
probe.stop();
// Both recorded before the decode verdict below, because the tests that need them do
// not need the backend to finish a clip - an early return here would leave them
// believing this backend starts playback and loads asynchronously when it does neither.
if (startsSynchronously) {
mSynchronousStartReason = qsl("This Qt Multimedia backend reaches PlayingState synchronously, so a player that has just been claimed by another track never reads as stopped and cannot "
"have its source cleared out from under it. Needs a backend that loads asynchronously, such as Qt's FFmpeg one.");
}
if (!sawPlaying && !startsSynchronously) {
// Without this every test that only stops a track - the ones that need nothing else
// of the backend - would fail its opening "never started playing" assertion rather
// than skip, which is a red build on any runner without a usable backend.
mCannotStartReason = qsl("This Qt Multimedia backend never reached PlayingState within 10s, so no playback can be started to act on. Backend: \"%1\", final media status: %2, error: "
"\"%3\".")
.arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")), QString::number(static_cast<int>(probe.mediaStatus())), probe.errorString());
}
if (!finished) {
// Report what was measured rather than a cause that was not diagnosed - no audio
// device, a missing codec and a stalled decoder all land here.
mCannotPlayReason = qsl("This Qt Multimedia backend did not reach EndOfMedia within 10s, so anything that waits for a clip to finish cannot be observed. Backend: \"%1\", reached "
"PlayingState: %2, final media status: %3, error: \"%4\".")
.arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")),
startsSynchronously ? qsl("yes") : qsl("no"),
QString::number(static_cast<int>(probe.mediaStatus())),
probe.errorString());
return;
}
if (!stoppedCameFirst) {
mWrongOrderReason = qsl("This Qt Multimedia backend emits EndOfMedia before StoppedState, so the loop restarts before any cleanup runs and issue #9566 cannot occur here. Needs a "
"StoppedState-first backend such as Qt's FFmpeg one.");
}
// Only worth asking of a backend that got this far. Assumed, not measured: one that
// cannot finish a valid clip is taken not to reject an invalid one either.
probeLoadFailureReporting();
}
// Whether an undecodable file is reported as an error at all. A backend that stays silent
// gives TMedia nothing to act on, so the release it cannot schedule cannot be asserted.
void probeLoadFailureReporting()
{
const QString path = qsl("%1/unplayable.wav").arg(mProbeDir.path());
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
QFAIL("Could not write the unplayable probe clip.");
}
file.write(QByteArray("not a WAV file, and not decodable as anything else"));
file.close();
QMediaPlayer probe;
auto* output = new QAudioOutput(&probe);
output->setMuted(true);
probe.setAudioOutput(output);
bool sawError = false;
connect(&probe, &QMediaPlayer::errorOccurred, this, [&](QMediaPlayer::Error error, const QString&) {
if (error != QMediaPlayer::NoError) {
sawError = true;
}
});
probe.setSource(QUrl::fromLocalFile(path));
probe.play();
const bool reported = QTest::qWaitFor(
[&]() {
return sawError;
},
QDeadlineTimer(10s));
probe.stop();
if (!reported) {
mNoLoadErrorReason = qsl("This Qt Multimedia backend does not report an error for an undecodable file within 10s, so there is no failure for TMedia to act on. Backend: \"%1\", final "
"media status: %2.")
.arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")), QString::number(static_cast<int>(probe.mediaStatus())));
}
}
TMediaData clipData(const QString& fileName) const
{
TMediaData data;
data.setMediaProtocol(TMediaData::MediaProtocolAPI);
data.setMediaType(TMediaData::MediaTypeMusic);
data.setMediaInput(TMediaData::MediaInputFile);
data.setMediaFileName(fileName);
data.setMediaVolume(1); // audible enough to play, quiet enough not to disturb a desktop run
return data;
}
// TMedia reports a player only while it is actually playing, or still loading - the
// carve-out that lets a stalled backend look busy, and the observable this turns on.
bool playing(TMedia* media, const QString& fileName) const
{
TMediaData criteria = clipData(fileName);
return !media->playingMedia(criteria).isEmpty();
}
bool waitForPlaying(TMedia* media, const QString& fileName, std::chrono::milliseconds timeout = 10s)
{
return QTest::qWaitFor(
[&]() {
return playing(media, fileName);
},
QDeadlineTimer(timeout));
}
// Stronger than waitForPlaying(): a player that is still loading counts as playing to
// TMedia, and it is not yet stoppable in the way a started one is - stopping it produces no
// playback state change, and getMediaPlayer() will not hand it to another track.
bool waitForPlaybackStarted(TMedia* media, const QString& fileName, std::chrono::milliseconds timeout = 10s)
{
return QTest::qWaitFor(
[&]() {
return playing(media, fileName) && media->playersInPlayingState() > 0;
},
QDeadlineTimer(timeout));
}
// Passes the file-name checks in TMedia::play(), so it reaches the backend and fails
// there, so the error path is reached the way a real undecodable file would reach it.
QString writeUnplayableClip(const QString& fileName) const { return writeClip(fileName, QByteArray("not a WAV file, and not decodable as anything else")); }
// Writes a clip into the profile media directory, returning {} if that fails.
QString writeClip(const QString& fileName, const QByteArray& contents = wavBytes()) const
{
const QString mediaPath = mudlet::getMudletPath(enums::profileMediaPath, mHostname);
if (!QDir().mkpath(mediaPath)) {
QTest::qFail("Could not create the profile media directory.", __FILE__, __LINE__);
return {};
}
QFile file(qsl("%1/%2").arg(mediaPath, fileName));
if (!file.open(QIODevice::WriteOnly)) {
QTest::qFail("Could not write the test media file.", __FILE__, __LINE__);
return {};
}
file.write(contents);
file.close();
return fileName;
}
// A silent 16-bit mono PCM WAV. Silence is fine: the tests assert on playback state
// transitions, not on what is heard.
static QByteArray wavBytes()
{
constexpr int sampleRate = 8000;
constexpr int bytesPerSample = 2;
const int dataBytes = sampleRate * bytesPerSample * clipMs / 1000;
QByteArray wav;
QDataStream out(&wav, QIODevice::WriteOnly);
out.setByteOrder(QDataStream::LittleEndian);
out.writeRawData("RIFF", 4);
out << static_cast<quint32>(36 + dataBytes);
out.writeRawData("WAVE", 4);
out.writeRawData("fmt ", 4);
out << static_cast<quint32>(16); // PCM header size
out << static_cast<quint16>(1); // PCM, uncompressed
out << static_cast<quint16>(1); // mono
out << static_cast<quint32>(sampleRate);
out << static_cast<quint32>(sampleRate * bytesPerSample); // byte rate
out << static_cast<quint16>(bytesPerSample); // block align
out << static_cast<quint16>(8 * bytesPerSample); // bits per sample
out.writeRawData("data", 4);
out << static_cast<quint32>(dataBytes);
wav.append(QByteArray(dataBytes, '\0'));
return wav;
}
// Utility function to manually start a profile like a user would do via the
// GUI
void startProfile(const QString& hostname, const QString& address, const QString& port)
{
QTimer::singleShot(0, qApp, [hostname, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), hostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(5s)) {
QFAIL("Profile took too long to load.");
}
}
// Utility function
void deleteProfileDirectory(const QString& profileName)
{
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
QDir dir(path);
if (!dir.exists()) {
qInfo() << "Profile directory does not exist:" << path;
return;
}
dir.removeRecursively();
}
};
void initializeQRCResourcesForMediaLoop()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
#include "TMediaLoopTest.moc"
QTEST_MAIN(TMediaLoopTest)