Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QtMultimediaTestLib/private/qmessagespy_p.h>
++ #include <QtSpatialAudio/qambientsound.h>
++ #include <QtSpatialAudio/qaudioengine.h>
++ #include <QtSpatialAudio/qaudiolistener.h>
++ #include <QtSpatialAudio/qaudioroom.h>
++ #include <QtSpatialAudio/qspatialsound.h>
++ #include <QtSpatialAudio/private/qtspatialaudioglobal_p.h>
++ #include <QtTest/qsignalspy.h>
++ #include <QtTest/qtest.h>
++ #include <QtTest/qtesteventloop.h>
++ #include <QtMultimedia/qaudio.h>
++ #include <QtMultimedia/qaudiodevice.h>
++ #include <QtMultimedia/qmediadevices.h>
++ #include <QtCore/qglobal.h>
++
++ #include <memory>
++
++ using namespace Qt::Literals;
++ using namespace std::chrono_literals;
++
++ using QtMultimediaPrivate::QLoggingCategoryEnabler;
++ using QtMultimediaPrivate::QMessageSpy;
++
++ class tst_QSpatialAudio : public QObject
++ {
++ Q_OBJECT
++ public:
++ tst_QSpatialAudio(QObject* parent=nullptr) : QObject(parent) {}
++
++ private slots:
++ void init();
++ void cleanup();
++
++ void initTestCase();
++ void test_QSpatialSound_basicProperties();
++ void test_QSpatialSound_rotation_and_directivityOrder_semantics();
++ void test_engine_positionDistanceScale();
++ void test_QSpatialSound_signalsEmittedOnChange();
++ void testEngineIntegration();
++ void testSetSource_autoPlay();
++ void testQAmbientSound_basic();
++ void testQAudioRoom_basic();
++ void testQAudioListener_basic();
++ void testSwitchAudioDevice();
++ void test_nullEngine_behaviour();
++
++ void testPlaybackControls();
++ void testFileLoading_replaceDuringPlayback();
++ void testFileLoading_loadInvalidFile();
++ void testAutoPlayTransitions();
++
++ void test_destroyRoomDuringPlayback();
++ void test_destroyListenerDuringPlayback();
++ void test_destroySpatialDuringPlayback();
++ void test_destroyAmbientDuringPlayback();
++
++ private:
++ std::unique_ptr<QSpatialSound> sound;
++ std::unique_ptr<QAudioEngine> engine;
++ std::optional<QLoggingCategoryEnabler> logEnabler;
++
++ QUrl url;
++ QUrl url2;
++ };
++
++ void tst_QSpatialAudio::init()
++ {
++ engine = std::make_unique<QAudioEngine>();
++ sound = std::make_unique<QSpatialSound>(engine.get());
++ logEnabler.emplace(qLcSpatialAudioEngine());
++ }
++
++ void tst_QSpatialAudio::cleanup()
++ {
++ sound.reset();
++ engine.reset();
++ logEnabler.reset();
++ }
++
++ void tst_QSpatialAudio::initTestCase()
++ {
++ // locate test audio file but do not fail tests if not present; playback tests will skip
++ QString testFileName = QStringLiteral("test.wav");
++ QString fullPath = QFINDTESTDATA(testFileName);
++ if (!fullPath.isEmpty())
++ url = QUrl::fromLocalFile(fullPath);
++
++ QString testFileName2 = QStringLiteral("test2.wav");
++ QString fullPath2 = QFINDTESTDATA(testFileName2);
++ if (!fullPath2.isEmpty())
++ url2 = QUrl::fromLocalFile(fullPath2);
++ }
++
++ void tst_QSpatialAudio::test_QSpatialSound_basicProperties()
++ {
++ // verify defaults and simple setters/getters
++ QCOMPARE(sound->loops(), 1);
++ QCOMPARE(sound->autoPlay(), true);
++
++ QSignalSpy loopsSpy(sound.get(), &QSpatialSound::loopsChanged);
++ sound->setLoops(QSpatialSound::Infinite);
++ QCOMPARE(sound->loops(), -1);
++ QCOMPARE(loopsSpy.size(), 1);
++ sound->setLoops(1);
++ QCOMPARE(loopsSpy.size(), 2);
++
++ QSignalSpy autoPlaySpy(sound.get(), &QSpatialSound::autoPlayChanged);
++ sound->setAutoPlay(false);
++ QCOMPARE(sound->autoPlay(), false);
++ QCOMPARE(autoPlaySpy.size(), 1);
++ sound->setAutoPlay(true);
++ QCOMPARE(autoPlaySpy.size(), 2);
++
++ QSignalSpy volumeSpy(sound.get(), &QSpatialSound::volumeChanged);
++ sound->setVolume(0.5f);
++ QCOMPARE(sound->volume(), 0.5f);
++ QCOMPARE(volumeSpy.size(), 1);
++
++ QSignalSpy directivitySpy(sound.get(), &QSpatialSound::directivityChanged);
++ sound->setDirectivity(0.2f);
++ QCOMPARE(sound->directivity(), 0.2f);
++ QCOMPARE(directivitySpy.size(), 1);
++
++ QSignalSpy directivityOrderSpy(sound.get(), &QSpatialSound::directivityOrderChanged);
++ sound->setDirectivityOrder(2.0f);
++ QCOMPARE(sound->directivityOrder(), 2.0f);
++ QCOMPARE(directivityOrderSpy.size(), 1);
++
++ QSignalSpy nfSpy(sound.get(), &QSpatialSound::nearFieldGainChanged);
++ sound->setNearFieldGain(0.3f);
++ QCOMPARE(sound->nearFieldGain(), 0.3f);
++ QCOMPARE(nfSpy.size(), 1);
++
++ QSignalSpy manualSpy(sound.get(), &QSpatialSound::manualAttenuationChanged);
++ sound->setManualAttenuation(0.7f);
++ QCOMPARE(sound->manualAttenuation(), 0.7f);
++ QCOMPARE(manualSpy.size(), 1);
++ }
++
++ void tst_QSpatialAudio::test_engine_positionDistanceScale()
++ {
++ // choose meter scale
++ engine->setDistanceScale(QAudioEngine::DistanceScaleMeter);
++
++ QVector3D pos(1.0f, 2.0f, 3.0f);
++ QSignalSpy spy(sound.get(), &QSpatialSound::positionChanged);
++ sound->setPosition(pos);
++ QCOMPARE(sound->position(), pos);
++ QCOMPARE(spy.size(), 1);
++
++ const int before = spy.size();
++ sound->setPosition(pos);
++ QCOMPARE(spy.size(), before);
++ }
++
++ void tst_QSpatialAudio::test_QSpatialSound_signalsEmittedOnChange()
++ {
++ QSignalSpy vspy(sound.get(), &QSpatialSound::volumeChanged);
++ sound->setVolume(0.25f);
++ QCOMPARE(vspy.size(), 1);
++ sound->setVolume(0.25f);
++ QCOMPARE(vspy.size(), 1);
++
++ QSignalSpy dspy(sound.get(), &QSpatialSound::distanceModelChanged);
++ sound->setDistanceModel(QSpatialSound::DistanceModel::Linear);
++ QCOMPARE(dspy.size(), 1);
++ sound->setDistanceModel(QSpatialSound::DistanceModel::Linear);
++ QCOMPARE(dspy.size(), 1);
++
++ QSignalSpy rotationSpy(sound.get(), &QSpatialSound::rotationChanged);
++ QQuaternion q(1, 1, 0, 0);
++ sound->setRotation(q);
++ QCOMPARE(rotationSpy.size(), 1);
++ sound->setRotation(q);
++ QCOMPARE(rotationSpy.size(), 1);
++
++ // Verify other property signals follow the expected "emit on change" semantics.
++ QSignalSpy loopsSpy(sound.get(), &QSpatialSound::loopsChanged);
++ sound->setLoops(QSpatialSound::Infinite);
++ QCOMPARE(loopsSpy.size(), 1);
++ sound->setLoops(QSpatialSound::Infinite);
++ QCOMPARE(loopsSpy.size(), 1);
++
++ QSignalSpy autoPlaySpy(sound.get(), &QSpatialSound::autoPlayChanged);
++ sound->setAutoPlay(false);
++ QCOMPARE(autoPlaySpy.size(), 1);
++ sound->setAutoPlay(false);
++ QCOMPARE(autoPlaySpy.size(), 1);
++
++ // size and distanceCutoff use the engine's distanceScale; ensure an
++ // engine is present before exercising those properties.
++ sound = {};
++ engine = std::make_unique<QAudioEngine>();
++ sound = std::make_unique<QSpatialSound>(engine.get());
++
++ QSignalSpy sizeSpy(sound.get(), &QSpatialSound::sizeChanged);
++ sound->setSize(2.5f);
++ QCOMPARE(sizeSpy.size(), 1);
++ sound->setSize(2.5f);
++ QCOMPARE(sizeSpy.size(), 1);
++
++ QSignalSpy cutoffSpy(sound.get(), &QSpatialSound::distanceCutoffChanged);
++ sound->setDistanceCutoff(3.5f);
++ QCOMPARE(cutoffSpy.size(), 1);
++ sound->setDistanceCutoff(3.5f);
++ QCOMPARE(cutoffSpy.size(), 1);
++
++ QSignalSpy manualSpy(sound.get(), &QSpatialSound::manualAttenuationChanged);
++ sound->setManualAttenuation(0.6f);
++ QCOMPARE(manualSpy.size(), 1);
++ sound->setManualAttenuation(0.6f);
++ QCOMPARE(manualSpy.size(), 1);
++
++ QSignalSpy occSpy(sound.get(), &QSpatialSound::occlusionIntensityChanged);
++ sound->setOcclusionIntensity(0.2f);
++ QCOMPARE(occSpy.size(), 1);
++ sound->setOcclusionIntensity(0.2f);
++ QCOMPARE(occSpy.size(), 1);
++
++ QSignalSpy dirSpy(sound.get(), &QSpatialSound::directivityChanged);
++ sound->setDirectivity(0.3f);
++ QCOMPARE(dirSpy.size(), 1);
++ sound->setDirectivity(0.3f);
++ QCOMPARE(dirSpy.size(), 1);
++
++ QSignalSpy dirOrderSpy(sound.get(), &QSpatialSound::directivityOrderChanged);
++ sound->setDirectivityOrder(2.0f);
++ QCOMPARE(dirOrderSpy.size(), 1);
++ sound->setDirectivityOrder(2.0f);
++ QCOMPARE(dirOrderSpy.size(), 1);
++
++ QSignalSpy nfSpy(sound.get(), &QSpatialSound::nearFieldGainChanged);
++ sound->setNearFieldGain(0.5f);
++ QCOMPARE(nfSpy.size(), 1);
++ sound->setNearFieldGain(0.5f);
++ QCOMPARE(nfSpy.size(), 1);
++ }
++
++ void tst_QSpatialAudio::test_QSpatialSound_rotation_and_directivityOrder_semantics()
++ {
++ // rotation semantics: should emit only on change
++ QSignalSpy rotationSpy(sound.get(), &QSpatialSound::rotationChanged);
++ QQuaternion q(1, 1, 0, 0);
++ sound->setRotation(q);
++ QVERIFY(rotationSpy.size() >= 1);
++ const int rotBefore = rotationSpy.size();
++ sound->setRotation(q);
++ QCOMPARE(rotationSpy.size(), rotBefore);
++
++ QSignalSpy ordSpy(sound.get(), &QSpatialSound::directivityOrderChanged);
++
++ const float orderVal = 3.0f;
++ const int ordBefore = ordSpy.size();
++ sound->setDirectivityOrder(orderVal);
++ const int ordAfter = ordSpy.size();
++ QCOMPARE(ordAfter - ordBefore, 1);
++
++ sound->setDirectivityOrder(orderVal);
++ QCOMPARE(ordAfter, ordSpy.size());
++ }
++
++ void tst_QSpatialAudio::testEngineIntegration()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ // basic engine lifecycle
++ engine->start();
++ QAudioEngine eng;
++ eng.start();
++ eng.stop();
++ }
++
++ void tst_QSpatialAudio::testSetSource_autoPlay()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ sound->setAutoPlay(false);
++ QSignalSpy srcSpy(sound.get(), &QSpatialSound::sourceChanged);
++ sound->setSource(url);
++ QCOMPARE(srcSpy.size(), 1);
++ // autoPlay is false, so playback state shouldn't start automatically
++ // We only check that calling play doesn't crash and can be invoked
++ sound->play();
++ QTest::qWait(1000); // allow 1 second of playback
++ sound->stop();
++ }
++
++ void tst_QSpatialAudio::testQAmbientSound_basic()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ auto ambient = std::make_unique<QAmbientSound>(engine.get());
++
++ QSignalSpy srcSpy(ambient.get(), &QAmbientSound::sourceChanged);
++ QSignalSpy volSpy(ambient.get(), &QAmbientSound::volumeChanged);
++
++ ambient->setAutoPlay(false);
++ QCOMPARE(ambient->autoPlay(), false);
++
++ ambient->setVolume(0.4f);
++ QCOMPARE(ambient->volume(), 0.4f);
++ QCOMPARE(volSpy.size(), 1);
++
++ if (!url.isEmpty()) {
++ ambient->setSource(url);
++ QCOMPARE(srcSpy.size(), 1);
++ }
++
++ ambient->play();
++ QTest::qWait(1000);
++ ambient->stop();
++ }
++
++ void tst_QSpatialAudio::testQAudioRoom_basic()
++ {
++ // exercise QAudioRoom properties and signals
++ auto room = std::make_unique<QAudioRoom>(engine.get());
++
++ // Position
++ QSignalSpy posSpy(room.get(), &QAudioRoom::positionChanged);
++ QVector3D newPos(1.0f, 2.0f, 3.0f);
++ room->setPosition(newPos);
++ QCOMPARE(room->position(), newPos);
++ QCOMPARE(posSpy.size(), 1);
++ room->setPosition(newPos);
++ QCOMPARE(posSpy.size(), 1);
++
++ // Dimensions
++ QSignalSpy dimSpy(room.get(), &QAudioRoom::dimensionsChanged);
++ QVector3D dims(10.0f, 11.0f, 12.0f);
++ room->setDimensions(dims);
++ QCOMPARE(room->dimensions(), dims);
++ QCOMPARE(dimSpy.size(), 1);
++ room->setDimensions(dims);
++ QCOMPARE(dimSpy.size(), 1);
++
++ // Rotation
++ QSignalSpy rotSpy(room.get(), &QAudioRoom::rotationChanged);
++ QQuaternion rot(1.0f, 1.0f, 0.0f, 0.0f);
++ room->setRotation(rot);
++ QCOMPARE(room->rotation(), rot);
++ QCOMPARE(rotSpy.size(), 1);
++ room->setRotation(rot);
++ QCOMPARE(rotSpy.size(), 1);
++
++ // Wall material + wallsChanged
++ QSignalSpy wallsSpy(room.get(), &QAudioRoom::wallsChanged);
++ room->setWallMaterial(QAudioRoom::LeftWall, QAudioRoom::BrickPainted);
++ QCOMPARE(room->wallMaterial(QAudioRoom::LeftWall), QAudioRoom::BrickPainted);
++ QCOMPARE(wallsSpy.size(), 1);
++ room->setWallMaterial(QAudioRoom::LeftWall, QAudioRoom::BrickPainted);
++ QCOMPARE(wallsSpy.size(), 1);
++
++ // Reflection / Reverb properties
++ QSignalSpy reflSpy(room.get(), &QAudioRoom::reflectionGainChanged);
++ QSignalSpy revGainSpy(room.get(), &QAudioRoom::reverbGainChanged);
++ QSignalSpy revTimeSpy(room.get(), &QAudioRoom::reverbTimeChanged);
++ QSignalSpy revBrightSpy(room.get(), &QAudioRoom::reverbBrightnessChanged);
++
++ room->setReflectionGain(0.25f);
++ QCOMPARE(room->reflectionGain(), 0.25f);
++ QCOMPARE(reflSpy.size(), 1);
++ room->setReflectionGain(0.25f);
++ QCOMPARE(reflSpy.size(), 1);
++
++ room->setReverbGain(0.4f);
++ QCOMPARE(room->reverbGain(), 0.4f);
++ QCOMPARE(revGainSpy.size(), 1);
++ room->setReverbGain(0.4f);
++ QCOMPARE(revGainSpy.size(), 1);
++
++ room->setReverbTime(1.2f);
++ QCOMPARE(room->reverbTime(), 1.2f);
++ QCOMPARE(revTimeSpy.size(), 1);
++ room->setReverbTime(1.2f);
++ QCOMPARE(revTimeSpy.size(), 1);
++
++ room->setReverbBrightness(0.75f);
++ QCOMPARE(room->reverbBrightness(), 0.75f);
++ QCOMPARE(revBrightSpy.size(), 1);
++ room->setReverbBrightness(0.75f);
++ QCOMPARE(revBrightSpy.size(), 1);
++ }
++
++ void tst_QSpatialAudio::testQAudioListener_basic()
++ {
++ QAudioEngine eng;
++ QAudioListener listener(&eng);
++ listener.setPosition({0,0,0});
++ QCOMPARE(listener.position(), QVector3D(0,0,0));
++ }
++
++ void tst_QSpatialAudio::testSwitchAudioDevice()
++ {
++ // Skip if less than 2 audio devices are available
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.size() < 2)
++ QSKIP("Needs at least 2 audio outputs");
++
++ // Get a non-default device
++ QAudioDevice nonDefaultDevice = [&] {
++ if (!outputs[0].isDefault())
++ return outputs[0];
++ return outputs[1];
++ }();
++ QAudioDevice anotherDevice = [&] {
++ return outputs[(outputs.indexOf(nonDefaultDevice) + 1) % outputs.size()];
++ }();
++
++ // Test 1: Switch device when engine is not running (should succeed)
++ engine->setOutputDevice(nonDefaultDevice);
++ QCOMPARE(engine->outputDevice(), nonDefaultDevice);
++
++ // Test 2: Switch device when engine is running (should warn)
++ QAudioDevice originalDevice = engine->outputDevice();
++
++ engine->start();
++
++ QMessageSpy warnSpy;
++ auto expectWarn =
++ warnSpy.expect(QtWarningMsg, "Changing device on a running engine not implemented");
++ engine->setOutputDevice(anotherDevice);
++ QVERIFY(expectWarn.wait());
++ QCOMPARE(engine->outputDevice(), originalDevice);
++
++ engine->stop();
++ }
++
++ void tst_QSpatialAudio::test_nullEngine_behaviour()
++ {
++ // Ensure constructing objects with a nullptr engine doesn't crash
++ QTest::ignoreMessage(QtWarningMsg, "Cannot create QSpatialSound without a valid QAudioEngine");
++ QSpatialSound nullSound(nullptr);
++
++ QVector3D pos(1, 2, 3);
++ nullSound.setPosition(pos);
++ QCOMPARE(nullSound.position(), QVector3D{});
++
++ nullSound.setSize(2.5f);
++ QCOMPARE(nullSound.size(), 0.f);
++
++ nullSound.setDistanceCutoff(3.5f);
++ QCOMPARE(nullSound.distanceCutoff(), 0.f);
++
++ nullSound.setRotation(QQuaternion(1, 0, 0, 0));
++ QVERIFY(!nullSound.rotation().isNull());
++
++ nullSound.setManualAttenuation(0.4f);
++ QCOMPARE(nullSound.manualAttenuation(), 0.f);
++
++ nullSound.setOcclusionIntensity(0.2f);
++ QCOMPARE(nullSound.occlusionIntensity(), 0.f);
++
++ nullSound.setDirectivity(0.1f);
++ QCOMPARE(nullSound.directivity(), 0.f);
++
++ nullSound.setNearFieldGain(0.5f);
++ QCOMPARE(nullSound.nearFieldGain(), 0.f);
++
++ // play/pause/stop should not crash
++ nullSound.play();
++ nullSound.pause();
++ nullSound.stop();
++
++ // AmbientSound with nullptr engine
++ QTest::ignoreMessage(QtWarningMsg, "Cannot create QAmbientSound without a valid QAudioEngine");
++ QAmbientSound nullAmbient(nullptr);
++ nullAmbient.setVolume(0.3f);
++ QCOMPARE(nullAmbient.volume(), 0.3f);
++ nullAmbient.play();
++ nullAmbient.pause();
++ nullAmbient.stop();
++
++ // QAudioListener with nullptr engine
++ QTest::ignoreMessage(QtWarningMsg, "Cannot create QAudioListener without a valid QAudioEngine");
++ QAudioListener nullListener(nullptr);
++ nullListener.setPosition({0,0,0});
++ QCOMPARE(nullListener.position(), QVector3D(0,0,0));
++ nullListener.setRotation(QQuaternion(1,0,0,0));
++ QCOMPARE(nullListener.engine(), nullptr);
++
++ // QAudioRoom with nullptr engine - mirror expectations for null-engine behaviour
++ QTest::ignoreMessage(QtWarningMsg, "Cannot create QAudioRoom without a valid QAudioEngine");
++ QAudioRoom nullRoom(nullptr);
++
++ nullRoom.setPosition(pos);
++ QCOMPARE(nullRoom.position(), QVector3D{});
++
++ nullRoom.setDimensions(QVector3D(10.0f, 11.0f, 12.0f));
++ QCOMPARE(nullRoom.dimensions(), QVector3D{});
++
++ nullRoom.setRotation(QQuaternion(1.0f, 0.0f, 0.0f, 0.0f));
++
++ nullRoom.setWallMaterial(QAudioRoom::LeftWall, QAudioRoom::BrickPainted);
++ QCOMPARE(nullRoom.wallMaterial(QAudioRoom::LeftWall), QAudioRoom::BrickPainted);
++ }
++
++ void tst_QSpatialAudio::testPlaybackControls()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ engine->start();
++
++ sound->setAutoPlay(false);
++
++ QMessageSpy playSpy(qLcSpatialAudioEngine());
++ auto expectLoading = playSpy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoaded = playSpy.expect(QtDebugMsg, "Sound loaded");
++ sound->setSource(url);
++ QVERIFY(expectLoading.wait());
++ QVERIFY(expectLoaded.wait());
++
++ auto expectPlay = playSpy.expect(QtDebugMsg, "Playing sound");
++ sound->play();
++ QVERIFY(expectPlay.wait());
++
++ auto expectPause = playSpy.expect(QtDebugMsg, "Pausing sound");
++ sound->pause();
++ QVERIFY(expectPause.wait());
++
++ auto expectPlay2 = playSpy.expect(QtDebugMsg, "Playing sound");
++ sound->play();
++ QVERIFY(expectPlay2.wait());
++
++ auto expectStop = playSpy.expect(QtDebugMsg, "Stopping sound");
++ sound->stop();
++ QVERIFY(expectStop.wait());
++ }
++
++ void tst_QSpatialAudio::testFileLoading_replaceDuringPlayback()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ engine->start();
++
++ sound->setAutoPlay(false);
++
++ QMessageSpy spy(qLcSpatialAudioEngine());
++ auto expectLoad1 = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: QUrl\\(.*test\\.wav.*\\)"));
++ auto expectLoaded1 = spy.expect(QtDebugMsg, "Sound loaded");
++ sound->setSource(url);
++ QVERIFY(expectLoad1.wait());
++ QVERIFY(expectLoaded1.wait());
++
++ auto expectPlay = spy.expect(QtDebugMsg, "Playing sound");
++ sound->play();
++ QVERIFY(expectPlay.wait());
++
++ // Replace the sound mid-playback
++ auto expectLoad2 = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: QUrl\\(.*test2\\.wav.*\\)"));
++ auto expectLoaded2 = spy.expect(QtDebugMsg, "Sound loaded");
++ auto expectPlaybackStarted = spy.expect(QtDebugMsg, "Sound playback started: playing");
++ sound->setSource(url2);
++ QVERIFY(expectLoad2.wait());
++ QVERIFY(expectLoaded2.wait());
++ QVERIFY(expectPlaybackStarted.wait());
++
++ auto expectStop = spy.expect(QtDebugMsg, "Stopping sound");
++ sound->stop();
++ QVERIFY(expectStop.wait());
++ }
++
++ void tst_QSpatialAudio::testFileLoading_loadInvalidFile()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ engine->start();
++
++ QMessageSpy spy(qLcSpatialAudioEngine());
++ auto expectLoadInvalid = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectWarnInvalid = spy.expect(QtWarningMsg, "QAmbientSound: cannot load file");
++ sound->setSource(QUrl("qrc://invalid_resource"));
++ QVERIFY(expectLoadInvalid.wait());
++ QVERIFY(expectWarnInvalid.wait());
++ }
++
++ void tst_QSpatialAudio::testAutoPlayTransitions()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ sound->setAutoPlay(true);
++
++ QMessageSpy spy(qLcSpatialAudioEngine());
++ auto expectLoadAuto = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoadedAuto = spy.expect(QtDebugMsg, "Sound loaded");
++ auto expectPlaybackAuto = spy.expect(QtDebugMsg, "Sound playback started: playing");
++ sound->setSource(url);
++ QVERIFY(expectLoadAuto.wait());
++ QVERIFY(expectLoadedAuto.wait());
++ QVERIFY(expectPlaybackAuto.wait());
++ }
++
++ void tst_QSpatialAudio::test_destroyRoomDuringPlayback()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ if (url.isEmpty())
++ QSKIP("No test audio file available");
++
++ auto listener = std::make_unique<QAudioListener>(engine.get());
++ listener->setPosition(QVector3D(0, 0, 0));
++ auto room = std::make_unique<QAudioRoom>(engine.get());
++ room->setDimensions(QVector3D(5, 5, 5));
++
++ engine->start();
++
++ QMessageSpy spy(qLcSpatialAudioEngine());
++
++ // Play a spatial sound and destroy a QAudioRoom during playback
++ auto spatial = std::make_unique<QSpatialSound>(engine.get());
++ spatial->setAutoPlay(false);
++ auto expectLoad =
++ spy.expect(QtDebugMsg, QRegularExpression("Loading sound: QUrl\\(.*test\\.wav.*\\)"));
++ auto expectLoaded = spy.expect(QtDebugMsg, "Sound loaded");
++ spatial->setSource(url);
++ QVERIFY(expectLoad.wait());
++ QVERIFY(expectLoaded.wait());
++
++ auto expectPlaying = spy.expect(QtDebugMsg, "Playing sound");
++ spatial->play();
++ QVERIFY(expectPlaying.wait());
++
++ room.reset(); // destroy while playback active
++
++ auto expectStopping = spy.expect(QtDebugMsg, "Stopping sound");
++ spatial->stop();
++ QVERIFY(expectStopping.wait());
++ spatial.reset();
++
++ engine->stop();
++ }
++
++ void tst_QSpatialAudio::test_destroyListenerDuringPlayback()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ if (url.isEmpty())
++ QSKIP("No test audio file available");
++
++ auto listener = std::make_unique<QAudioListener>(engine.get());
++ listener->setPosition(QVector3D(0, 0, 0));
++
++ auto room = std::make_unique<QAudioRoom>(engine.get());
++ room->setDimensions(QVector3D(5, 5, 5));
++
++ engine->start();
++
++ // Use QMessageSpy to observe engine log messages instead of ignoring them.
++ QMessageSpy spy(qLcSpatialAudioEngine());
++
++ auto spatial = std::make_unique<QSpatialSound>(engine.get());
++ spatial->setAutoPlay(false);
++ spatial->setLoops(-1);
++
++ auto expectLoad = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoaded = spy.expect(QtDebugMsg, "Sound loaded");
++ spatial->setSource(url);
++ QVERIFY(expectLoad.wait());
++ QVERIFY(expectLoaded.wait());
++
++ auto expectPlaying = spy.expect(QtDebugMsg, "Playing sound");
++ spatial->play();
++ QVERIFY(expectPlaying.wait());
++
++ listener.reset(); // destroy while playback active
++
++ auto expectStopping = spy.expect(QtDebugMsg, "Stopping sound");
++ spatial->stop();
++ QVERIFY(expectStopping.wait());
++ spatial.reset();
++
++ engine->stop();
++ }
++
++ void tst_QSpatialAudio::test_destroySpatialDuringPlayback()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ if (url.isEmpty())
++ QSKIP("No test audio file available");
++
++ auto listener = std::make_unique<QAudioListener>(engine.get());
++ listener->setPosition(QVector3D(0, 0, 0));
++ auto room = std::make_unique<QAudioRoom>(engine.get());
++ room->setDimensions(QVector3D(5, 5, 5));
++
++ engine->start();
++
++ // Use QMessageSpy to observe engine log messages instead of ignoring them.
++ QMessageSpy spy(qLcSpatialAudioEngine());
++
++ auto spatial = std::make_unique<QSpatialSound>(engine.get());
++ spatial->setAutoPlay(false);
++ spatial->setLoops(-1);
++
++ auto expectLoad = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoaded = spy.expect(QtDebugMsg, "Sound loaded");
++ spatial->setSource(url);
++ QVERIFY(expectLoad.wait());
++ QVERIFY(expectLoaded.wait());
++
++ auto expectPlaying = spy.expect(QtDebugMsg, "Playing sound");
++ spatial->play();
++ QVERIFY(expectPlaying.wait());
++
++ // Destroy the spatial sound while playback is active
++ auto expectStopping = spy.expect(QtDebugMsg, "Stopping sound");
++ spatial.reset();
++ QVERIFY(expectStopping.wait());
++
++ // Ensure engine still usable: create a new sound and play briefly
++ auto s2 = std::make_unique<QSpatialSound>(engine.get());
++ s2->setAutoPlay(false);
++
++ auto expectLoad2 = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoaded2 = spy.expect(QtDebugMsg, "Sound loaded");
++ s2->setSource(url);
++ QVERIFY(expectLoad2.wait());
++ QVERIFY(expectLoaded2.wait());
++
++ auto expectPlay2 = spy.expect(QtDebugMsg, "Playing sound");
++ s2->play();
++ QVERIFY(expectPlay2.wait());
++
++ auto expectStop2 = spy.expect(QtDebugMsg, "Stopping sound");
++ s2->stop();
++ QVERIFY(expectStop2.wait());
++ s2.reset();
++
++ engine->stop();
++ }
++
++ void tst_QSpatialAudio::test_destroyAmbientDuringPlayback()
++ {
++ QList outputs = QMediaDevices::audioOutputs();
++ if (outputs.isEmpty())
++ QSKIP("No audio outputs available");
++
++ if (url.isEmpty())
++ QSKIP("No test audio file available");
++
++ auto listener = std::make_unique<QAudioListener>(engine.get());
++ listener->setPosition(QVector3D(0, 0, 0));
++ auto room = std::make_unique<QAudioRoom>(engine.get());
++ room->setDimensions(QVector3D(5, 5, 5));
++
++ engine->start();
++
++ // Use QMessageSpy to observe engine log messages instead of ignoring them.
++ QMessageSpy spy(qLcSpatialAudioEngine());
++
++ auto ambient = std::make_unique<QAmbientSound>(engine.get());
++ ambient->setAutoPlay(false);
++ ambient->setLoops(-1);
++
++ auto expectLoad = spy.expect(QtDebugMsg, QRegularExpression("Loading sound: .*"));
++ auto expectLoaded = spy.expect(QtDebugMsg, "Sound loaded");
++ ambient->setSource(url);
++ QVERIFY(expectLoad.wait());
++ QVERIFY(expectLoaded.wait());
++
++ auto expectPlaying = spy.expect(QtDebugMsg, "Playing sound");
++ ambient->play();
++ QVERIFY(expectPlaying.wait());
++
++ auto expectStopping = spy.expect(QtDebugMsg, "Stopping sound");
++ ambient.reset(); // destroy while playback active
++ QVERIFY(expectStopping.wait());
++
++ engine->stop();
++ }
++
++ QTEST_MAIN(tst_QSpatialAudio)
++
++ #include "tst_qspatialaudio.moc"