mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from the map engine: `TMap.{h,cpp}`
no longer owns a `QProgressDialog` (and drops a dead `QFileDialog`
include), and `XMLimport.{h,cpp}` no longer pulls in `QApplication` (the
clipboard read now uses `QGuiApplication::clipboard()`, which lives in
Qt Gui).
- The standalone map-progress dialog (shown when the mapper is not
visible, for map download / XML import and JSON export/import) is now
driven by Qt signals carrying pre-translated payloads; the frontend
(`TMainConsole`) owns the actual `QProgressDialog`, and a user cancel
returns to the engine through `TMap::slot_mapProgressDialogCancelled()`.
- Adds `MapProgressDialogSeamTest` covering the transfer-progress state
machine, a JSON export/import round trip driving the new signals, a
mid-import cancel delivered through the seam (the highest-risk change,
since the JSON reader used to poll `QProgressDialog::wasCanceled()`
synchronously), and an XML map import re-entered from inside a running
JSON operation.
#### Motivation for adding to Mudlet
Second concrete step of the re-scoped libmudlet plan (a Qt-Widgets-free
`mudlet_core` for headless use, testability and WASM). It copies the
seam template established in #9507: core emits a pre-translated payload
-> frontend owns the widget -> a callback slot returns the answer. The
Qt Widgets dependency audit (`cmake/audit-core-widgets.sh`) drops from
**151 to 147** offending files; `TMap.cpp`, `TMap.h`, `XMLimport.cpp`
and `XMLimport.h` are all now clean. The mapper-owned inline progress
path (`dlgMapper`/`T2DMap`, Mudlet's own widgets) is deliberately
untouched here - those move wholesale in the later target-split phase.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Existing translations are unaffected: every
progress string keeps its `TMap` `tr()` context, so current translations
carry straight over. Two new strings do arrive, both with `//:`
translator comments - the warnings shown when a map download or an XML
map import is refused because a JSON import/export is already running.
The JSON dialog stays non-modal and the download/import dialog keeps its
modeless styling, each applied by the frontend. The engine keeps its own
`mMapProgressStandalone` / `mMapProgressCancelRequested` /
`mMapProgressStandaloneMaximum` state to replace the widget read-backs
it used to do (`!= nullptr`, `wasCanceled()`, `maximum()`). If a map
operation ever reaches the engine before a console is wired (checked via
`isSignalConnected`), `TMap::warnIfMapProgressUnwired()` logs a loud
`qWarning` rather than silently running with no progress UI.
It also closes a latent null-dereference that exists on `development`
today. With the mapper visible a map download takes the inline-progress
path, leaving `mpProgressDialog` null - so a JSON export started
meanwhile sails past the `if (mpProgressDialog)` "already in progress"
check and creates a dialog of its own. When the download then finishes
inside the `processEvents()` pump the export is running,
`clearTransferProgress()` deletes and nulls *that* dialog, and the
export's next `incrementJsonProgressDialog()` dereferences null. The
engine now records whose dialog is up (`mMapProgressIsTransfer`) so a
transfer only ever closes its own, and `importMap()` refuses to start
while a JSON operation holds the progress - the mirror of the guard
`downloadMap()` has.
Two review-driven details worth flagging: the frontend only wires the
dialog's cancel to the engine when the operation is actually cancelable,
so a non-cancelable local XML import no longer turns a window-close into
a spurious "Map download was canceled" message; and the standalone
download/import dialog is now parented to the console (like the JSON one
always was, and like #9507's package-download dialog), so it centres on
and dies with the profile window. The three `#include <QApplication>`
additions to `Host.cpp` / `dlgTriggerEditor.cpp` /
`dlgConnectionProfiles.cpp` replace the transitive include they used to
get from `XMLimport.h`; all three are already Qt Widgets consumers, so
the audit count is unaffected.
Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-opus-5
**Test case:** With a mapper window open, use a game that supports map
download (or call `downloadMap()`) and confirm the progress dialog
shows, updates, and its Abort cancels the download. Then with the mapper
window closed, run `exportJsonMap()` and `importJsonMap()` on a large
map and confirm the non-modal JSON progress dialog appears, updates its
Areas/Rooms/Labels counts, and that clicking Abort during an import
stops it with an "aborted by user" result. Load a local XML map
(Settings -> Map -> load) and confirm closing its progress window does
not print a "Map download was canceled" line. Everything should behave
exactly as on `development`.
#### Demo (before & after)
https://github.com/user-attachments/assets/f1c62580-2d03-4e5b-a6ee-f6e2b78d113d
297 lines
13 KiB
C++
297 lines
13 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. *
|
|
***************************************************************************/
|
|
|
|
/*
|
|
* Tests the map-progress seam introduced for the libmudlet split (#8681,
|
|
* #9011): the Qt-Widgets-free TMap no longer owns a QProgressDialog and instead
|
|
* emits pre-translated payloads for the frontend to render, while cancellation
|
|
* returns through TMap::slot_mapProgressDialogCancelled().
|
|
*
|
|
* These tests stand in for the frontend with plain signal recorders and drive
|
|
* the engine directly, so they verify the engine half of the seam without any
|
|
* widget:
|
|
* - the download/XML transfer-progress state machine emits the right signals
|
|
* and keeps its own maximum/active state (the old QProgressDialog read-backs)
|
|
* - a JSON export and re-import announce and close their progress dialogs and
|
|
* leave no stuck "operation already in progress" state
|
|
* - a cancel delivered through the seam mid-import makes the JSON reader abort,
|
|
* the exact behaviour that used to depend on QProgressDialog::wasCanceled()
|
|
*
|
|
* Run with: ctest -R MapProgressDialogSeamTest -V
|
|
*/
|
|
|
|
#include <QtTest/QtTest>
|
|
|
|
#include <QSignalSpy>
|
|
#include <QTemporaryDir>
|
|
|
|
#include "Host.h"
|
|
#include "HostManager.h"
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TMap.h"
|
|
#include "TRoomDB.h"
|
|
#include "mudlet.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 initializeQRCResourcesForMapProgressDialogSeamTest();
|
|
|
|
class MapProgressDialogSeamTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
Host* mpSource = nullptr;
|
|
Host* mpTarget = nullptr;
|
|
const QString mSourceName = qsl("MapProgressSeamSource-Test");
|
|
const QString mTargetName = qsl("MapProgressSeamTarget-Test");
|
|
QTemporaryDir mSaveDir;
|
|
|
|
void buildSmallMap(Host* pHost)
|
|
{
|
|
TMap* pMap = pHost->mpMap.data();
|
|
TRoomDB* pDB = pMap->mpRoomDB.get();
|
|
const int areaA = pDB->addArea(qsl("Area A"));
|
|
const int areaB = pDB->addArea(qsl("Area B"));
|
|
QVERIFY(areaA > 0);
|
|
QVERIFY(areaB > 0);
|
|
int id = 1;
|
|
for (const int areaId : {areaA, areaB}) {
|
|
for (int i = 0; i < 3; ++i, ++id) {
|
|
QVERIFY(pMap->addRoom(id));
|
|
QVERIFY(pMap->setRoomArea(id, areaId, false));
|
|
QVERIFY(pMap->setRoomCoordinates(id, i, i, 0));
|
|
}
|
|
}
|
|
}
|
|
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
{
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
QDir dir(path);
|
|
if (dir.exists()) {
|
|
dir.removeRecursively();
|
|
}
|
|
}
|
|
|
|
private slots:
|
|
void initTestCase()
|
|
{
|
|
initializeQRCResourcesForMapProgressDialogSeamTest();
|
|
|
|
mudlet::start();
|
|
mudlet::self()->setupConfig();
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
|
mudlet::self()->init();
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
|
deleteProfileDirectory(mSourceName);
|
|
deleteProfileDirectory(mTargetName);
|
|
|
|
QVERIFY(mSaveDir.isValid());
|
|
|
|
auto& hostManager = mudlet::self()->getHostManager();
|
|
QVERIFY2(hostManager.addHost(mSourceName, qsl("23"), QString(), QString()), "failed to create the source Host");
|
|
mpSource = hostManager.getHost(mSourceName);
|
|
QVERIFY(mpSource);
|
|
QVERIFY2(hostManager.addHost(mTargetName, qsl("23"), QString(), QString()), "failed to create the target Host");
|
|
mpTarget = hostManager.getHost(mTargetName);
|
|
QVERIFY(mpTarget);
|
|
|
|
buildSmallMap(mpSource);
|
|
if (QTest::currentTestFailed()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
void cleanupTestCase()
|
|
{
|
|
mpSource = nullptr;
|
|
mpTarget = nullptr;
|
|
deleteProfileDirectory(mSourceName);
|
|
deleteProfileDirectory(mTargetName);
|
|
delete mudlet::self();
|
|
}
|
|
|
|
// The download/XML transfer path: with no visible mapper the engine takes
|
|
// the standalone-dialog branch, which must now be pure signals plus the
|
|
// engine-side state that replaced the QProgressDialog read-backs.
|
|
void test_transferProgressStateMachine()
|
|
{
|
|
TMap* pMap = mpSource->mpMap.data();
|
|
QSignalSpy startSpy(pMap, &TMap::signal_mapTransferProgressStart);
|
|
QSignalSpy rangeSpy(pMap, &TMap::signal_mapProgressSetRange);
|
|
QSignalSpy valueSpy(pMap, &TMap::signal_mapProgressSetValue);
|
|
QSignalSpy labelSpy(pMap, &TMap::signal_mapProgressSetLabel);
|
|
QSignalSpy disableSpy(pMap, &TMap::signal_mapProgressDisableCancel);
|
|
QSignalSpy closeSpy(pMap, &TMap::signal_mapProgressClose);
|
|
QVERIFY(startSpy.isValid());
|
|
|
|
QVERIFY(!pMap->hasActiveTransferProgress());
|
|
|
|
pMap->createTransferProgress(qsl("A title"), qsl("A label"), true);
|
|
QCOMPARE(startSpy.count(), 1);
|
|
QCOMPARE(startSpy.at(0).at(0).toString(), qsl("A title"));
|
|
QCOMPARE(startSpy.at(0).at(1).toString(), qsl("A label"));
|
|
// cancelable == true carries the pre-translated Abort button text:
|
|
QCOMPARE(startSpy.at(0).at(2).toString(), qsl("Abort"));
|
|
QVERIFY(pMap->hasActiveTransferProgress());
|
|
QCOMPARE(pMap->transferProgressMaximum(), 0);
|
|
|
|
pMap->updateTransferProgressRange(0, 100);
|
|
QCOMPARE(rangeSpy.count(), 1);
|
|
QCOMPARE(rangeSpy.at(0).at(1).toInt(), 100);
|
|
// Read-back must come from the engine's cached maximum, not a widget:
|
|
QCOMPARE(pMap->transferProgressMaximum(), 100);
|
|
|
|
pMap->updateTransferProgressValue(42);
|
|
QCOMPARE(valueSpy.count(), 1);
|
|
QCOMPARE(valueSpy.at(0).at(0).toInt(), 42);
|
|
|
|
pMap->updateTransferProgressLabel(qsl("Working"));
|
|
QCOMPARE(labelSpy.count(), 1);
|
|
QCOMPARE(labelSpy.at(0).at(0).toString(), qsl("Working"));
|
|
|
|
pMap->disableTransferProgressCancel();
|
|
QCOMPARE(disableSpy.count(), 1);
|
|
|
|
pMap->clearTransferProgress();
|
|
QCOMPARE(closeSpy.count(), 1);
|
|
QVERIFY(!pMap->hasActiveTransferProgress());
|
|
}
|
|
|
|
void test_jsonExportImportDrivesProgressSignals()
|
|
{
|
|
TMap* pSourceMap = mpSource->mpMap.data();
|
|
const QString file = qsl("%1/seam.json").arg(mSaveDir.path());
|
|
|
|
QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart);
|
|
QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose);
|
|
const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file);
|
|
QVERIFY2(wrote, qPrintable(writeMsg));
|
|
QCOMPARE(exportStartSpy.count(), 1);
|
|
QCOMPARE(exportStartSpy.at(0).at(0).toString(), qsl("Map JSON export"));
|
|
// Exactly one close: a second would mean the dialog was torn down twice:
|
|
QCOMPARE(exportCloseSpy.count(), 1);
|
|
// The engine must not stay "in progress" (that would reject the next op):
|
|
QVERIFY(!pSourceMap->hasActiveTransferProgress());
|
|
|
|
TMap* pTargetMap = mpTarget->mpMap.data();
|
|
QSignalSpy importStartSpy(pTargetMap, &TMap::signal_mapJsonProgressStart);
|
|
QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose);
|
|
const auto [read, readMsg] = pTargetMap->readJsonMapFile(file);
|
|
QVERIFY2(read, qPrintable(readMsg));
|
|
QCOMPARE(importStartSpy.count(), 1);
|
|
QCOMPARE(importStartSpy.at(0).at(0).toString(), qsl("Map JSON import"));
|
|
QCOMPARE(importCloseSpy.count(), 1);
|
|
QVERIFY(!pTargetMap->hasActiveTransferProgress());
|
|
}
|
|
|
|
// The highest-risk seam: the JSON reader used to poll
|
|
// QProgressDialog::wasCanceled(); it now polls a flag set by
|
|
// slot_mapProgressDialogCancelled(). Acting as the frontend, deliver a
|
|
// cancel the instant the import announces its dialog and confirm the read
|
|
// aborts with the user-cancel result and clears its state.
|
|
void test_jsonImportCancellationAbortsViaSeam()
|
|
{
|
|
TMap* pSourceMap = mpSource->mpMap.data();
|
|
const QString file = qsl("%1/cancel.json").arg(mSaveDir.path());
|
|
// Spying on both ends of a dialog's life also stands in for a wired-up
|
|
// frontend, so the engine's "nobody is showing this" warning stays quiet:
|
|
QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart);
|
|
QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose);
|
|
const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file);
|
|
QVERIFY2(wrote, qPrintable(writeMsg));
|
|
QCOMPARE(exportStartSpy.count(), 1);
|
|
QCOMPARE(exportCloseSpy.count(), 1);
|
|
|
|
TMap* pTargetMap = mpTarget->mpMap.data();
|
|
QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose);
|
|
const QMetaObject::Connection cancelOnStart = connect(pTargetMap, &TMap::signal_mapJsonProgressStart, pTargetMap, [pTargetMap]() {
|
|
pTargetMap->slot_mapProgressDialogCancelled();
|
|
});
|
|
const auto [read, readMsg] = pTargetMap->readJsonMapFile(file);
|
|
disconnect(cancelOnStart);
|
|
|
|
QVERIFY(!read);
|
|
QCOMPARE(readMsg, qsl("aborted by user"));
|
|
// An aborted import must still take its progress dialog down with it:
|
|
QCOMPARE(importCloseSpy.count(), 1);
|
|
QVERIFY(!pTargetMap->hasActiveTransferProgress());
|
|
}
|
|
|
|
// An XML map import started while a JSON operation owns the progress dialog
|
|
// must be refused: readXmlMapFile() would otherwise mistake the JSON
|
|
// operation's dialog for its own and mapClear() the map mid-import. The
|
|
// re-entrancy is real - a Lua loadMap() from a timer lands in the
|
|
// qApp->processEvents() the JSON reader pumps.
|
|
void test_xmlImportRefusedWhileJsonOperationOwnsProgress()
|
|
{
|
|
TMap* pSourceMap = mpSource->mpMap.data();
|
|
const QString file = qsl("%1/reentrancy.json").arg(mSaveDir.path());
|
|
QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart);
|
|
QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose);
|
|
const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file);
|
|
QVERIFY2(wrote, qPrintable(writeMsg));
|
|
QCOMPARE(exportStartSpy.count(), 1);
|
|
QCOMPARE(exportCloseSpy.count(), 1);
|
|
|
|
TMap* pTargetMap = mpTarget->mpMap.data();
|
|
QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose);
|
|
bool importAttempted = false;
|
|
bool importAccepted = true;
|
|
QString importError;
|
|
const QMetaObject::Connection reenter = connect(pTargetMap, &TMap::signal_mapJsonProgressStart, pTargetMap, [&]() {
|
|
importAttempted = true;
|
|
QFile xmlMap(qsl("%1/no-such-map.xml").arg(mSaveDir.path()));
|
|
importAccepted = pTargetMap->importMap(xmlMap, &importError);
|
|
});
|
|
const auto [read, readMsg] = pTargetMap->readJsonMapFile(file);
|
|
disconnect(reenter);
|
|
|
|
QVERIFY(importAttempted);
|
|
QVERIFY2(!importAccepted, "importMap() ran on top of an in-flight JSON import");
|
|
// Refused by the in-progress guard, not by failing to read the file:
|
|
QVERIFY2(importError.contains(qsl("already in progress")), qPrintable(importError));
|
|
// ...and the JSON operation it interrupted still completed:
|
|
QVERIFY2(read, qPrintable(readMsg));
|
|
QCOMPARE(importCloseSpy.count(), 1);
|
|
QVERIFY(!pTargetMap->hasActiveTransferProgress());
|
|
}
|
|
};
|
|
|
|
void initializeQRCResourcesForMapProgressDialogSeamTest()
|
|
{
|
|
#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 "MapProgressDialogSeamTest.moc"
|
|
QTEST_MAIN(MapProgressDialogSeamTest)
|