mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
243 lines
11 KiB
C++
243 lines
11 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
|
* *
|
|
* 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. *
|
|
***************************************************************************/
|
|
|
|
/*
|
|
* Regression guard for #9659: an interrupting ttsSpeak() dropping the utterance
|
|
* it was asked to speak.
|
|
*
|
|
* Every speech engine Qt wraps stops the running utterance inside say() and
|
|
* reports Ready for it - speech-dispatcher, SAPI, WinRT and AVFoundation all
|
|
* do. Mudlet raises its TTS events off those state changes and drains
|
|
* ttsQueue() on any Ready, so that one was read as "the engine is idle": the
|
|
* queued line was spoken straight over the utterance the script had just asked
|
|
* for, and that utterance was never heard at all.
|
|
*
|
|
* Qt's mock engine, which the Lua specs in Media_spec.lua use, never reports
|
|
* that Ready - it stays in Speaking with no state change at all, which is the
|
|
* other half of the same issue and is covered there. So the guard itself needs
|
|
* that Ready delivered the way a real engine delivers it, which is what this
|
|
* test does: it drives the Lua API for everything else and hands
|
|
* TLuaInterpreter::ttsStateChanged() the state change the engine's
|
|
* QTextToSpeech::stateChanged signal would have carried.
|
|
*
|
|
* Run with: ctest -R TtsInterruptingSpeakTest -V
|
|
*/
|
|
|
|
#include <QtTest/QtTest>
|
|
|
|
#include <QTemporaryDir>
|
|
|
|
#include "Host.h"
|
|
#include "HostManager.h"
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TLuaInterpreter.h"
|
|
#include "TScript.h"
|
|
#include "ScriptUnit.h"
|
|
#include "mudlet.h"
|
|
|
|
#ifdef QT_TEXTTOSPEECH_LIB
|
|
#include <QTextToSpeech>
|
|
#endif
|
|
|
|
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 initializeQRCResourcesForTtsInterruptingSpeakTest();
|
|
|
|
class TtsInterruptingSpeakTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
Host* mpHost = nullptr;
|
|
const QString mProfileName = qsl("TtsInterruptingSpeak-Test");
|
|
QTemporaryDir mConfigDir;
|
|
QByteArray mSavedXdg;
|
|
|
|
#ifdef QT_TEXTTOSPEECH_LIB
|
|
// Runs a snippet in the profile's Lua state, failing the test with the
|
|
// script's own error message when it does not run cleanly. Lua assert()s in
|
|
// the snippet are how the queue and the current line are read back.
|
|
bool runLua(const QString& script) { return mpHost->getLuaInterpreter()->compileAndExecuteScript(script); }
|
|
|
|
// Counts ttsSpeechStarted from here on in the Lua global ttsStartedCount. A
|
|
// TScript rather than registerAnonymousEventHandler(): this console-less
|
|
// Host never loads LuaGlobals.lua, where that function is defined.
|
|
bool countStartedEvents()
|
|
{
|
|
auto pScript = new TScript(nullptr, mpHost);
|
|
mpHost->getScriptUnit()->registerScript(pScript);
|
|
pScript->setName(qsl("ttsStartedCounter"));
|
|
if (!pScript->setScript(qsl("ttsStartedCount = 0\nfunction ttsStartedCounter(event, text)\n ttsStartedCount = ttsStartedCount + 1\nend\n"))) {
|
|
return false;
|
|
}
|
|
pScript->setEventHandlerList(QStringList{qsl("ttsSpeechStarted")});
|
|
pScript->setIsActive(true);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
private slots:
|
|
void initTestCase()
|
|
{
|
|
initializeQRCResourcesForTtsInterruptingSpeakTest();
|
|
|
|
QVERIFY(mConfigDir.isValid());
|
|
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
|
|
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
|
|
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
|
|
// Picked up by TLuaInterpreter::ttsBuild(), which then asks for Qt's
|
|
// deterministic mock engine instead of whatever the host machine would
|
|
// otherwise speak out loud.
|
|
qputenv("MUDLET_TEST_MODE", "1");
|
|
|
|
mudlet::start();
|
|
mudlet::self()->setupConfig();
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
|
mudlet::self()->init();
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
|
|
|
QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, qsl("23"), QString(), QString()), "failed to create the Host");
|
|
mpHost = mudlet::self()->getHostManager().getHost(mProfileName);
|
|
QVERIFY(mpHost);
|
|
// A bare Host blocks script compilation until the full profile boot
|
|
// would clear this, and these tests need their snippets to compile:
|
|
mpHost->mBlockScriptCompile = false;
|
|
}
|
|
|
|
void cleanupTestCase()
|
|
{
|
|
mpHost = nullptr;
|
|
delete mudlet::self();
|
|
qunsetenv("MUDLET_TEST_MODE");
|
|
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
|
|
}
|
|
|
|
// The TTS state - engine, queue, and the flags this fixes - is process
|
|
// global, so it is reset after every test method rather than at the end of
|
|
// each, where an assertion that fails would jump over it.
|
|
void cleanup()
|
|
{
|
|
#ifdef QT_TEXTTOSPEECH_LIB
|
|
if (mpHost) {
|
|
runLua(qsl("ttsClearQueue() ttsSkip()"));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void test_theReadyFromAnInterruptedUtteranceDoesNotDrainTheQueue()
|
|
{
|
|
#ifndef QT_TEXTTOSPEECH_LIB
|
|
QSKIP("Mudlet was built without text-to-speech support");
|
|
#else
|
|
QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state");
|
|
if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) {
|
|
QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak");
|
|
}
|
|
|
|
QVERIFY(runLua(qsl("ttsSpeak('the utterance already being spoken')")));
|
|
QVERIFY(runLua(qsl("ttsQueue('the queued line')")));
|
|
QVERIFY(runLua(qsl("ttsSpeak('the utterance the script asked for')")));
|
|
|
|
// What every real engine reports next: the utterance say() stopped has
|
|
// ended. It is not the engine falling idle, and the queue must survive
|
|
// it - the requested utterance is what should be being spoken.
|
|
TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready);
|
|
|
|
QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 1, 'the queued line was spoken over the utterance ttsSpeak() asked for, queue holds '..#ttsGetQueue())")),
|
|
"the queue was drained by the Ready that reported the interrupted utterance ending");
|
|
QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the utterance the script asked for', 'the current line became: '..tostring(ttsGetCurrentLine()))")),
|
|
"the drained line replaced the utterance the script asked for");
|
|
|
|
// The engine getting round to reporting that the requested utterance
|
|
// started must not announce it a second time: ttsSpeak() already did,
|
|
// there being no state edge at the time for it to have come from.
|
|
QVERIFY2(countStartedEvents(), "could not install the event handler that counts ttsSpeechStarted");
|
|
TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Speaking);
|
|
QVERIFY2(runLua(qsl("assert(ttsStartedCount == 0, 'the utterance was announced '..ttsStartedCount..' more time(s)')")),
|
|
"the engine's late Speaking announced the same utterance a second time");
|
|
#endif
|
|
}
|
|
|
|
// The control for the test above: with nothing interrupted, that same Ready
|
|
// is the engine going idle and has to drain the queue. Without this, a
|
|
// change that stopped ttsStateChanged() draining at all would leave the
|
|
// test above passing while the queue feature was dead.
|
|
void test_theReadyFromAnUninterruptedUtteranceStillDrainsTheQueue()
|
|
{
|
|
#ifndef QT_TEXTTOSPEECH_LIB
|
|
QSKIP("Mudlet was built without text-to-speech support");
|
|
#else
|
|
QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state");
|
|
if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) {
|
|
QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak");
|
|
}
|
|
|
|
QVERIFY(runLua(qsl("ttsSpeak('the only utterance')")));
|
|
QVERIFY(runLua(qsl("ttsQueue('the queued line')")));
|
|
|
|
TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready);
|
|
|
|
QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 0, 'the queue was not drained, it holds '..#ttsGetQueue())")), "an idle engine left the queue undrained");
|
|
QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the queued line', 'the current line is: '..tostring(ttsGetCurrentLine()))")), "the drain did not start speaking the queued line");
|
|
#endif
|
|
}
|
|
|
|
// The other side of the same guard: an explicit stop really does leave the
|
|
// engine idle, so its Ready has to keep draining the queue as it always has.
|
|
void test_theReadyFromAnExplicitSkipStillDrainsTheQueue()
|
|
{
|
|
#ifndef QT_TEXTTOSPEECH_LIB
|
|
QSKIP("Mudlet was built without text-to-speech support");
|
|
#else
|
|
QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state");
|
|
if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) {
|
|
QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak");
|
|
}
|
|
|
|
QVERIFY(runLua(qsl("ttsSpeak('the utterance being spoken over')")));
|
|
QVERIFY(runLua(qsl("ttsSpeak('the utterance the script asked for')")));
|
|
QVERIFY(runLua(qsl("ttsQueue('the queued line')")));
|
|
QVERIFY(runLua(qsl("ttsSkip()")));
|
|
|
|
QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 0, 'the queue still holds '..#ttsGetQueue())")), "an explicit skip left the queue undrained");
|
|
QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the queued line', 'the current line is: '..tostring(ttsGetCurrentLine()))")), "the skip did not start speaking the queued line");
|
|
#endif
|
|
}
|
|
};
|
|
|
|
void initializeQRCResourcesForTtsInterruptingSpeakTest()
|
|
{
|
|
#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 "TtsInterruptingSpeakTest.moc"
|
|
QTEST_MAIN(TtsInterruptingSpeakTest)
|