mudlet/test/functional_tests/StarterUiTriggerCostTest.cpp
Vadim Peretokin 848dee43ea
improve: base UI captures your gossips in its chat window (#9838)
#### Brief overview of PR changes/additions
- The base UI's chat capture now recognises `You gossip, 'test!'`-style
lines and copies them into the Tells tab
- New `you gossip` shape under the tells gate (18 of 20 gate substrings
used), mirrored in `chatPatterns` so gossip lines stay out of the vitals
harvester
- Package version 1.3.0, `.mpackage` rebuilt; corpus lines added to
StarterUiTriggerCostTest and UI_spec

#### Motivation for adding to Mudlet
Gossip is a staple channel on Diku-style games, and the starter UI
missed these lines entirely.

#### Other info (issues closed, discussion etc)
Only your own gossips are captured for now - the incoming `Bob gossips,
'...'` counterpart can be added the same way if wanted.

**Test case:** `ctest -R StarterUiTriggerCost`, or gossip on any
Diku-style game with the base UI up and see the line land in the Tells
tab.

Assisted-by: Claude:claude-fable-5
2026-08-13 08:06:37 +02:00

739 lines
34 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. *
***************************************************************************/
// The starter UI (mudlet-base-ui) is preinstalled into new profiles, so every
// always-active trigger it arms is matched against every line the game sends.
// This pins what that costs and that the capture layers still capture.
#include <QtTest/QtTest>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TLuaInterpreter.h"
#include "TTrigger.h"
#include "TelnetServerStub.h"
#include "TriggerUnit.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "dlgTriggerEditor.h"
#include <QTreeWidget>
#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();
static void initializeQRCResources();
class StarterUiTriggerCostTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
const QString mHostname = qsl("Test-StarterUiTriggerCost");
const QString mLocalhost = qsl("localhost");
quint16 mPort = 0;
// A full default-package profile measures 3: the starter UI's chat capture
// tree and its vitals prefilter, plus one folder from another package.
// Nesting hides growth from this count, so kMaxGatePatterns is what tracks
// the per-line cost.
static constexpr int kMaxRootTriggers = 5;
// What every line of game text really pays for: the substrings the chat
// gates scan for before any regex runs. Measures 18. Raising this is a
// throughput change and wants measuring first.
static constexpr int kMaxGatePatterns = 20;
private slots:
void initTestCase() { initializeQRCResources(); }
void init()
{
mpServer = new TelnetServerStub(qApp);
// Ephemeral port so parallel worktree runs never collide.
mpServer->start(mLocalhost, 0);
mPort = mpServer->serverPort();
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mHostname);
}
void cleanup()
{
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mHostname);
delete mudlet::self();
}
void test_captureLayersArmAHandfulOfTriggersNotOnePerShape()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
const int rootTriggers = static_cast<int>(host->getTriggerUnit()->getTriggerRootNodeList().size());
QVERIFY2(rootTriggers > 0, "no triggers are registered at all - the profile did not finish loading its packages");
QVERIFY2(rootTriggers <= kMaxRootTriggers,
qPrintable(qsl("a new user's profile arms %1 always-active root triggers, and every line of game "
"text is matched against all of them")
.arg(rootTriggers)));
QVERIFY(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1")));
QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "the chat capture tree's gates did not come up armed");
}
// Miss a line here and that game's gauges silently never appear.
//
// The label list in the script is hand-written, not derived from the
// package's label tables, so this catches a new shape whose spelling is
// missing from the prefilter but not a new spelling bolted onto an existing
// shape - add spellings to promptLabels and friends, not inline.
void test_thePrefilterMatchesEveryLineTheShapesRead()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
QVERIFY2(runLua(host, prefilterDifferentialScript()), "the prefilter differential did not run - see the profile's error console");
QVERIFY2(luaTrue(host, qsl("__starterUi.misses == 0")), "the vitals prefilter drops lines the shapes read - the first few are in the error console");
// Without this the assertion above holds vacuously.
QVERIFY2(luaTrue(host, qsl("__starterUi.readable > 1500")), "the generated corpus stopped producing readings, so the prefilter check proved nothing");
QVERIFY2(luaTrue(host, qsl("__starterUi.shapesFired == __starterUi.shapeCount")),
"the generated corpus no longer exercises every vitals shape - a new shape needs a layout or label "
"adding to the lists in prefilterDifferentialScript()");
}
// The fallback to a pattern string still works, so nothing else fails when
// a shape is recompiled per line.
void test_theShapesArePrecompiled()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
QVERIFY2(luaTrue(host, qsl("BaseUI.shapesArePrecompiled()")), "a chat or vitals shape is not a compiled regex object, so it is recompiled on every line");
}
// A label-after-the-numbers prompt is read only by recurrence-gated shapes,
// so this pins the gate as well: nothing until the third sighting.
void test_aPlainTextPromptStillDrivesTheGauges()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
feedLine(host, qsl("<523/600hp 210/250m 80/100mv>"));
QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp == nil")), "a gated prompt shape drove the gauges on first sight");
feedLine(host, qsl("<522/600hp 209/250m 79/100mv>"));
QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.hp == nil")));
feedLine(host, qsl("<521/600hp 208/250m 78/100mv>"));
QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.max == 600")),
"a recurring cur/max prompt no longer reaches the gauges - the prefilter is dropping lines the "
"vitals shapes read");
QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.hp.current == 521")));
QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mv ~= nil and BaseUI.vitalsData.mv.max == 100")));
}
void test_aPercentagePromptStillDrivesTheGauges()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
for (int i = 0; i < 3; ++i) {
feedLine(host, qsl("<87%hp 80%m>"));
}
QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.percent == 87")), "a recurring percentage prompt no longer reaches the gauges");
QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mp.percent == 80")));
}
// A current-only prompt cannot supply a maximum, so the layer sends "score"
// once - the one capture path with a visible side effect on the game.
void test_aCurrentOnlyPromptStillAsksTheGameForItsScoreScreen()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
QVERIFY(luaTrue(host, qsl("not BaseUI.scoreRequested")));
for (int i = 0; i < 3; ++i) {
feedLine(host, qsl("<523hp 210m 80mv>"));
}
QVERIFY2(luaTrue(host, qsl("BaseUI.scoreRequested")),
"a current-only prompt no longer reaches BaseUI.maybeRequestScore, so games whose prompt carries no "
"maximum never get gauges");
}
// Score-screen rows are trusted on first sight: a score may only be shown once.
void test_aScoreScreenIsStillReadOnFirstSight()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
feedLine(host, qsl("Health: 3600/3600 Mana: 3400/3400"));
QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.max == 3600")), "a score-screen row was not read on first sight");
QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mp ~= nil and BaseUI.vitalsData.mp.max == 3400")));
// Chat is conversation, not a prompt: a tell quoting numbers must not
// move the gauges, which is the chat shapes being consulted from the
// vitals path.
feedLine(host, qsl("Bob tells you, 'I am somehow alive at 11/12 hp'"));
QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp.max == 3600")), "a tell was harvested for vitals");
}
// Every chat shape needs a line here. The tree's gates are
// case-sensitive substrings, so a shape whose literal is spelled differently
// in its gate silently never routes, and only a line exercising that shape
// notices.
void test_chatCaptureStillSortsLinesIntoTheirTabs()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
// second is the tab besides All the line has to reach, empty for the
// shapes that only ever reach All
const QList<QPair<QString, QString>> corpus = {
{qsl("Bob tells you, 'hello there'"), qsl("tells")},
{qsl("You tell Ann, 'on my way'"), qsl("tells")},
{qsl("You tell the formation you are ready."), qsl("tells")},
{qsl("Ann whispers to you, 'psst'"), qsl("tells")},
{qsl("Bob tells the group, 'incoming'"), qsl("tells")},
{qsl("You gossip, 'test!'"), qsl("tells")},
{qsl("Bob says, 'hello everyone'"), QString()},
{qsl("Bob asks, 'where is the bank?'"), QString()},
{qsl("Bob exclaims, 'at last!'"), QString()},
{qsl("You say, 'hi there'"), QString()},
{qsl("You ask, 'which way?'"), QString()},
{qsl("You exclaim, 'finally!'"), QString()},
{qsl("Bob yells, 'help!'"), QString()},
{qsl("Bob shouts, 'to arms!'"), QString()},
{qsl("You yell, 'wait for me!'"), QString()},
{qsl("You shout, 'over here!'"), QString()},
{qsl("[tell] Ann: are you there?"), qsl("tells")},
{qsl("[newbie] Ann: how do I get out of here?"), qsl("channels")},
{qsl("(gossip) Ann: anyone around?"), qsl("channels")},
{qsl("< chat | Ann: anyone around?"), qsl("channels")},
};
QHash<QString, int> expectedUnread;
for (const auto& [text, family] : corpus) {
feedLine(host, text);
// routeChatLine() records what it copied, and counts the active tab
// (All) as read, so this is what says the line reached the dock
QVERIFY2(luaTrue(host, qsl("BaseUI.recentCaptures[#BaseUI.recentCaptures] ~= nil and BaseUI.recentCaptures[#BaseUI.recentCaptures].text == %1").arg(luaLiteral(text))),
qPrintable(qsl("the chat tree did not route: %1").arg(text)));
if (family.isEmpty()) {
continue;
}
expectedUnread[family] += 1;
QVERIFY2(luaTrue(host, qsl("BaseUI.unread.%1 == %2").arg(family, QString::number(expectedUnread.value(family)))), qPrintable(qsl("did not reach the %1 tab: %2").arg(family, text)));
}
// BaseUI.chatChannelNames has the last word on a captured tag, so this
// line reaches no tab at all rather than only missing Channels.
QVERIFY(runLua(host, qsl("__starterUiCaptures = #BaseUI.recentCaptures")));
feedLine(host, qsl("[inventory] a rusty sword"));
QVERIFY2(luaTrue(host, qsl("#BaseUI.recentCaptures == __starterUiCaptures")), "an unknown tag was captured as chat");
QVERIFY(runLua(host, chatShapeCoverageScript(corpus)));
QVERIFY2(luaTrue(host, qsl("__starterUi.uncoveredShapes == 0")),
"a chat shape has no line in the corpus above, so nothing would notice if its gate stopped matching - "
"the shapes are named in the error console");
QVERIFY2(luaTrue(host, qsl("__starterUi.unroutedLines == 0")),
"a corpus line routes through the trigger tree but no chatPatterns shape recognises it, so the vitals "
"layer would harvest it - the lines are in the error console");
assertCorpusCoversEveryGateLiteral(host, corpus);
}
// The gate layer is what every line pays for, so it has to stay substring
// matching, and the shapes hanging off it have to stay the ones the script
// knows about - chatLikeLine() reads that list to keep chat out of the
// vitals layer, and a shape only the tree has would be harvested for gauges.
void test_theChatTreeGatesOnSubstringsAndKeepsTheScriptsShapes()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
TTrigger* tree = findTrigger(host, qsl("Mudlet base UI chat capture"));
QVERIFY2(tree, "the chat capture tree is not in the profile under the name the script arms");
QVERIFY2(tree->getPatternsList().isEmpty(), "the chat tree's root folder grew a pattern, so it is a chain now and no longer passes every line to its gates");
int gatePatterns = 0;
QStringList shapes;
for (auto gate : *tree->getChildrenList()) {
const QList<int> kinds = gate->getRegexCodePropertyList();
QVERIFY2(!kinds.isEmpty(), qPrintable(qsl("gate \"%1\" has no patterns, so it passes every line straight to its regexes").arg(gate->getName())));
for (const int kind : kinds) {
QVERIFY2(kind == REGEX_SUBSTRING || kind == REGEX_BEGIN_OF_LINE_SUBSTRING,
qPrintable(qsl("gate \"%1\" has a pattern of kind %2 - a gate must be substring matching only, or every line pays for a regex").arg(gate->getName(), QString::number(kind))));
}
gatePatterns += static_cast<int>(kinds.size());
for (auto shape : *gate->getChildrenList()) {
shapes << shape->getPatternsList();
}
}
QVERIFY2(gatePatterns <= kMaxGatePatterns,
qPrintable(qsl("the chat gates scan every line for %1 substrings, over the budget of %2").arg(QString::number(gatePatterns), QString::number(kMaxGatePatterns))));
QVERIFY(runLua(host, treeShapeComparisonScript(shapes)));
QVERIFY2(luaTrue(host, qsl("__starterUi.shapeMismatch == nil")),
"the tree's shapes and the script's chatPatterns have drifted apart - chatLikeLine() would stop recognising a "
"line the tree routes, and the vitals layer would read it as a prompt. The first difference is in the error console");
}
// A game that sends chat over GMCP does not need the gates, but the next
// connection might.
void test_theChatLayerRetiresOnceGmcpChatAppears()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
QVERIFY(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")));
QVERIFY(runLua(host,
qsl("gmcp = gmcp or {}\n"
"gmcp.Comm = { Channel = { Text = { channel = 'chat', text = 'hello there' } } }\n"
"BaseUI.addChatMessage()")));
QVERIFY2(luaTrue(host, qsl("BaseUI.gmcpChat")), "a GMCP chat message did not retire the trigger layer");
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the chat gates stayed armed after GMCP chat arrived, so lines would be captured twice");
QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()")));
QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "the chat gates did not re-arm after a disconnect");
QVERIFY(runLua(host, qsl("BaseUI.hide()")));
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "\"baseui hide\" left the chat gates armed");
QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()")));
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "a disconnect re-armed the chat gates of a hidden UI");
QVERIFY(runLua(host, qsl("BaseUI.show()")));
QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "\"baseui show\" did not bring the chat gates back");
}
// Shipping the tree visible invites players to copy it, and the editor's
// paste keeps every name. enableTrigger()/disableTrigger() can only name a
// trigger, so a lifecycle built on a name a copy reproduces would reach
// into the player's triggers - and their active copy would answer for ours
// when we asked whether the layer was armed.
void test_copyingTheTreeLeavesThePlayersTriggersAlone()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
auto* tree = triggerTreeWidget(host);
QVERIFY2(tree, "could not reach the editor's trigger tree");
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTreeShared()")), "a fresh profile already has more than one chat capture tree");
QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()")));
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the layer did not disarm on a profile with no copies");
QVERIFY(runLua(host, qsl("BaseUI.armChatTriggers()")));
QVERIFY(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")));
// the likely copy: one gate, to adapt for themselves
pasteCopyOf(tree, qsl("BaseUI chat: tells"));
QList<TTrigger*> gates;
collectByName(host, qsl("BaseUI chat: tells"), gates);
QCOMPARE(gates.size(), 2);
QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()")));
QVERIFY2(gates.at(1)->isActive(), "retiring the chat layer switched off the player's copy of a gate");
QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the player's copy answered for ours when we asked whether the layer was armed");
QVERIFY(runLua(host, qsl("BaseUI.armChatTriggers()")));
// and the whole tree, name and all - now we cannot tell ours apart
pasteCopyOf(tree, qsl("Mudlet base UI chat capture"));
QVERIFY2(luaTrue(host, qsl("BaseUI.chatTreeShared()")), "a second tree with our folder's name went unnoticed");
QList<TTrigger*> folders;
collectByName(host, qsl("Mudlet base UI chat capture"), folders);
QCOMPARE(folders.size(), 2);
QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()")));
QVERIFY2(folders.at(1)->isActive(), "retiring the chat layer switched off the player's copy of the whole tree");
// giving up the switch is only safe because routeChatLine() re-checks
QVERIFY(runLua(host, qsl("__starterUiCaptures = #BaseUI.recentCaptures\nBaseUI.gmcpChat = true")));
feedLine(host, qsl("Ann whispers to you, 'psst'"));
QVERIFY2(luaTrue(host, qsl("#BaseUI.recentCaptures == __starterUiCaptures")),
"with the switch given up, a chat line was still captured after GMCP chat took over - the per-line guard is what "
"keeps this correct and it did not hold");
}
void test_theVitalsLayerRetiresOnceAProtocolOwnsTheGauges()
{
Host* host = startProfileWithStarterUi();
QVERIFY(host);
QVERIFY(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1")));
QVERIFY(runLua(host,
qsl("gmcp = gmcp or {}\n"
"gmcp.Char = { Vitals = { hp = 500, maxhp = 600 } }\n"
"BaseUI.updateVitals()")));
QVERIFY2(luaTrue(host, qsl("BaseUI.structuredVitalsOwnGauges()")), "GMCP vitals did not take the source lock");
QVERIFY2(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 0")), "the plain-text vitals triggers stayed armed after GMCP took the gauges over");
// The next connection may have no protocol at all.
QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()")));
QVERIFY2(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1")), "the vitals layer did not re-arm after a disconnect");
}
private:
// Quotes and backslashes in a corpus line have to reach the Lua state
// exactly as they were fed to the trigger engine.
static QString luaLiteral(const QString& text) { return qsl("[==[%1]==]").arg(text); }
// Drives the editor the way a player does: its copy/paste goes through the
// same XML export/import as a package, so this covers both.
QTreeWidget* triggerTreeWidget(Host* host)
{
dlgTriggerEditor* editor = host->mpEditorDialog;
if (!editor) {
return nullptr;
}
QMetaObject::invokeMethod(editor, "slot_showTriggers");
QCoreApplication::processEvents();
auto* tree = editor->findChild<QTreeWidget*>(qsl("treeWidget_triggers"));
return (tree && tree->topLevelItemCount() > 0) ? tree : nullptr;
}
void pasteCopyOf(QTreeWidget* tree, const QString& name)
{
dlgTriggerEditor* editor = tree->window()->findChild<dlgTriggerEditor*>();
editor = editor ? editor : qobject_cast<dlgTriggerEditor*>(tree->window());
QVERIFY(editor);
// the editor rebuilds the tree after a paste, so nothing may be cached
QTreeWidgetItem* base = tree->topLevelItem(0);
QVERIFY(base);
QTreeWidgetItem* item = findItem(base, name);
QVERIFY2(item, qPrintable(qsl("no editor tree item called %1").arg(name)));
tree->setCurrentItem(item);
QCoreApplication::processEvents();
QMetaObject::invokeMethod(editor, "slot_copyXml");
QCoreApplication::processEvents();
tree->setCurrentItem(tree->topLevelItem(0));
QCoreApplication::processEvents();
QMetaObject::invokeMethod(editor, "slot_pasteXml");
QCoreApplication::processEvents();
}
static QTreeWidgetItem* findItem(QTreeWidgetItem* parent, const QString& name)
{
if (!parent) {
return nullptr;
}
if (parent->text(0) == name) {
return parent;
}
for (int i = 0; i < parent->childCount(); ++i) {
if (QTreeWidgetItem* found = findItem(parent->child(i), name)) {
return found;
}
}
return nullptr;
}
static void collectByName(Host* host, const QString& name, QList<TTrigger*>& found)
{
for (auto root : host->getTriggerUnit()->getTriggerRootNodeList()) {
collectByNameIn(root, name, found);
}
}
static void collectByNameIn(TTrigger* trigger, const QString& name, QList<TTrigger*>& found)
{
if (trigger->getName() == name) {
found << trigger;
}
for (auto child : *trigger->getChildrenList()) {
collectByNameIn(child, name, found);
}
}
static TTrigger* findTrigger(Host* host, const QString& name)
{
for (auto root : host->getTriggerUnit()->getTriggerRootNodeList()) {
if (TTrigger* found = findTriggerIn(root, name)) {
return found;
}
}
return nullptr;
}
static TTrigger* findTriggerIn(TTrigger* trigger, const QString& name)
{
if (trigger->getName() == name) {
return trigger;
}
for (auto child : *trigger->getChildrenList()) {
if (TTrigger* found = findTriggerIn(child, name)) {
return found;
}
}
return nullptr;
}
void assertCorpusCoversEveryGateLiteral(Host* host, const QList<QPair<QString, QString>>& corpus)
{
TTrigger* tree = findTrigger(host, qsl("Mudlet base UI chat capture"));
QVERIFY(tree);
for (auto gate : *tree->getChildrenList()) {
const QStringList patterns = gate->getPatternsList();
const QList<int> kinds = gate->getRegexCodePropertyList();
for (int i = 0; i < patterns.size() && i < kinds.size(); ++i) {
const QString& literal = patterns.at(i);
bool exercised = false;
for (const auto& [text, family] : corpus) {
// matching the engine: substrings are indexOf, the rest startsWith
exercised = kinds.at(i) == REGEX_SUBSTRING ? text.contains(literal, Qt::CaseSensitive) : text.startsWith(literal, Qt::CaseSensitive);
if (exercised) {
break;
}
}
QVERIFY2(exercised,
qPrintable(qsl("no corpus line exercises \"%1\" in gate \"%2\", so that literal could be misspelled and "
"the chat it gates would silently stop appearing")
.arg(literal, gate->getName())));
}
}
}
static QString treeShapeComparisonScript(const QStringList& treeShapes)
{
QStringList entries;
for (const QString& shape : treeShapes) {
entries << luaLiteral(shape);
}
return qsl(R"LUA(
local tree = { %1 }
local script = BaseUI.chatShapeRegexes()
__starterUi = __starterUi or {}
__starterUi.shapeMismatch = nil
for i = 1, math.max(#tree, #script) do
if tree[i] ~= script[i] then
__starterUi.shapeMismatch = string.format("shape %d: tree has %s, chatPatterns has %s",
i, tostring(tree[i]), tostring(script[i]))
echo("\n[ chat shape drift ] " .. __starterUi.shapeMismatch .. "\n")
break
end
end
)LUA")
.arg(entries.join(qsl(", ")));
}
// Holds the corpus fed above against the shapes chatLikeLine() walks: every
// shape needs a line, and every line needs a shape.
static QString chatShapeCoverageScript(const QList<QPair<QString, QString>>& corpus)
{
QStringList entries;
for (const auto& entry : corpus) {
entries << luaLiteral(entry.first);
}
return qsl(R"LUA(
local corpus = { %1 }
__starterUi = { uncoveredShapes = 0, unroutedLines = 0 }
for _, regex in ipairs(BaseUI.chatShapeRegexes()) do
local covered = false
for _, text in ipairs(corpus) do
if rex.find(text, regex) then
covered = true
break
end
end
if not covered then
__starterUi.uncoveredShapes = __starterUi.uncoveredShapes + 1
echo("\n[ chat shape with no corpus line ] " .. regex .. "\n")
end
end
for _, text in ipairs(corpus) do
if not BaseUI.chatLikeLine(text) then
__starterUi.unroutedLines = __starterUi.unroutedLines + 1
echo("\n[ corpus line no chat shape recognises ] " .. text .. "\n")
end
end
)LUA")
.arg(entries.join(qsl(", ")));
}
// Runs inside the profile's Lua state, against the real
// BaseUI.parseVitalsLine and BaseUI.vitalsPrefilter.
static QString prefilterDifferentialScript()
{
return qsl(R"LUA(
local labels = {
"hp", "health", "hit", "hits", "hitpoint", "hitpoints", "hit point", "hit points", "h",
"mp", "mana", "sp", "magic", "energy", "blood", "spell point", "spell points", "spellpoints", "m",
"mv", "move", "moves", "movement", "movements", "move point", "move points", "movement points",
"stamina", "st", "endurance", "end", "vitality",
"xp", "exp", "experience", "experience point", "experience points", "exp points", "tnl",
}
local templates = {
"@: 100/120", "@ 100/120", "@100/120", "100/120 @", "100/120@", "100 / 120 @",
"@: 87%", "87% @", "@ 87%", "87%@", "@87%",
"@: 100", "100 @", "100@",
"| @: 100/120 |", "| @ : 100/120 |", "@ : 100 of 120", "@: 100(120)", "@ 100 ( 120 )",
"You have 100/120 @.", "You have 100(120) @.", "You have 100/120 @points.",
"You have 100/120 @ and 50/60 mana.", "You have 100/120 @ left.",
"Level: 5 @: 100/120 Pager ( )", "@ : [ 100/120 ]", "@: 12,345/23,456",
"PRACT: 005 @: 90 of 90", " @: 3600/3600 Mana: 3400/3400",
"#### @ 100/120 ####", "50 @(50).",
"| @: 100(120) |", "| @ : 100 of 120 |", "| Race: Undead | @: 4252/4252 |",
}
__starterUi = { misses = 0, readable = 0, shapeSeen = {} }
local reported = 0
for _, label in ipairs(labels) do
for _, template in ipairs(templates) do
for _, spelling in ipairs({ label, label:sub(1, 1):upper() .. label:sub(2), label:upper() }) do
local line = template:gsub("@", spelling)
local readings = BaseUI.parseVitalsLine(line)
if #readings > 0 then
__starterUi.readable = __starterUi.readable + 1
for _, reading in ipairs(readings) do
__starterUi.shapeSeen[reading.pattern] = true
end
-- rex.find: rex.match returns false for an unset capture group
if not rex.find(line, BaseUI.vitalsPrefilter) then
__starterUi.misses = __starterUi.misses + 1
if reported < 5 then
reported = reported + 1
echo("\n[ prefilter MISS ] " .. line .. "\n")
end
end
end
end
end
end
__starterUi.shapesFired = 0
for _ in pairs(__starterUi.shapeSeen) do
__starterUi.shapesFired = __starterUi.shapesFired + 1
end
__starterUi.shapeCount = BaseUI.vitalsShapeCount()
)LUA");
}
Host* startProfileWithStarterUi()
{
startProfile();
Host* host = mudlet::self()->getActiveHost();
if (!host) {
return nullptr;
}
host->mEchoLuaErrors = true;
// Installed by hand only when the preinstall gate did not, so this test
// says nothing about who counts as a new user.
if (!host->mInstalledPackages.contains(qsl("mudlet-base-ui"))) {
auto [installed, message] = host->installPackage(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage"), enums::PackageModuleType::Package, true);
if (!installed) {
qWarning("%s", qPrintable(qsl("could not install the starter UI: %1").arg(message)));
return nullptr;
}
}
// A hidden or stood-aside setting would suppress every capture trigger.
if (!luaTrue(host, qsl("type(BaseUI) == 'table' and not BaseUI.dormant()"))) {
qWarning("the starter UI did not load, or loaded dormant");
return nullptr;
}
return host;
}
// Mirrors the helper the other functional tests use.
void startProfile()
{
const QString port = QString::number(mPort);
QTimer::singleShot(0, qApp, [this, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), mHostname);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy loaded(mudlet::self(), &mudlet::signal_profileLoaded);
if (!loaded.wait(5000)) {
QFAIL("Profile took too long to load.");
}
Host* host = mudlet::self()->getActiveHost();
if (!host) {
QFAIL("No active host available for the test.");
}
QSignalSpy connected(&(host->mTelnet), &cTelnet::signal_connected);
if (!connected.wait(3000)) {
QFAIL("Could not connect to the stub.");
}
}
void feedLine(Host* host, const QString& text)
{
QByteArray data = text.toUtf8() + "\r\n";
data.reserve(data.size() + 16);
host->mTelnet.loopbackTest(data);
}
bool runLua(Host* host, const QString& script) { return host->getLuaInterpreter()->compileAndExecuteScript(script); }
bool luaTrue(Host* host, const QString& expression)
{
if (!runLua(host, qsl("__starterUiProbe = not not (%1)").arg(expression))) {
qWarning("%s", qPrintable(qsl("probe did not compile: %1").arg(expression)));
return false;
}
const bool result = runLua(host, qsl("assert(__starterUiProbe)"));
if (!result) {
qWarning("%s", qPrintable(qsl("probe is false: %1").arg(expression)));
}
return result;
}
void deleteProfileDirectory(const QString& profileName)
{
QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName));
if (dir.exists()) {
dir.removeRecursively();
}
}
};
static void initializeQRCResources()
{
#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 "StarterUiTriggerCostTest.moc"
QTEST_MAIN(StarterUiTriggerCostTest)