mudlet/test/TLinkStoreTest.cpp
Mike Conley a7fbe7f9d3
fix: clickable links could show the wrong colours and styling after heavy output (#9406)
#### Brief overview of PR changes/additions
- Clear a recycled link id's stale styling and selection-group index
entry in `TLinkStore::addLinks()` when the id counter wraps around
- Add unit test `testStylingClearedOnIdReuse` covering the wraparound
case

#### Motivation for adding to Mudlet
When a game has produced more than 20,000 clickable links, recycled link
ids kept the previous link's OSC 8 styling (colours, decorations,
selection group) until the new link happened to set its own - so plain
MXP/echoLink links could render with another link's styling and respond
to the wrong selection group.

#### Other info (issues closed, discussion etc)
Closes #9404. Mirrors the bookkeeping `removeLinkById()` already
performs; `addLinks()` previously cleaned up only the expire-name
mapping on id reuse.

**Test case:** `TLinkStoreTest` covers it (new test fails without the
fix). Manually: on a profile with a small link store this needs 20k+
links to trigger; the unit test exercises the wraparound directly.

---------

Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-07-12 20:45:24 +02:00

266 lines
7.5 KiB
C++

#include <TLinkStore.h>
#include <QtTest/QtTest>
class TLinkStoreTest : public QObject
{
Q_OBJECT
private:
private slots:
void initTestCase() {}
void testAddAndGet()
{
TLinkStore store(3);
QStringList links;
links.append("GET &href;");
links.append("LOOK &href;");
QStringList hints;
hints.append("Get &href;");
hints.append("Look at &href;");
int id = store.addLinks(links, hints);
QStringList links2 = store.getLinks(id);
QCOMPARE(links2, links);
QCOMPARE(links2[1], "LOOK &href;");
QStringList hints2 = store.getHints(id);
QCOMPARE(hints2, hints);
QCOMPARE(hints2[1], "Look at &href;");
}
void testNewGeneratedID()
{
TLinkStore store(3);
QStringList links;
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 1);
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 2);
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 3);
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 1);
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 2);
}
void testMaxId()
{
TLinkStore store(3);
QStringList links;
links.append("GET &href;");
store.addLinks(links, links);
store.addLinks(links, links);
store.addLinks(links, links);
QCOMPARE(store.getCurrentLinkID(), 3);
QCOMPARE(store.getLinks(3), links);
}
void testRemoveUnreferencedLinks()
{
TLinkStore store(10);
QStringList links1;
links1.append("command1");
QStringList hints1;
hints1.append("hint1");
QStringList links2;
links2.append("command2");
QStringList hints2;
hints2.append("hint2");
QStringList links3;
links3.append("command3");
QStringList hints3;
hints3.append("hint3");
int id1 = store.addLinks(links1, hints1);
int id2 = store.addLinks(links2, hints2);
int id3 = store.addLinks(links3, hints3);
// Verify all links exist
QCOMPARE(store.getLinksConst(id1), links1);
QCOMPARE(store.getLinksConst(id2), links2);
QCOMPARE(store.getLinksConst(id3), links3);
// Simulate only id2 is still referenced in buffer
QSet<int> referencedIds;
referencedIds.insert(id2);
store.removeUnreferencedLinks(referencedIds, nullptr);
// id2 should still exist
QCOMPARE(store.getLinksConst(id2), links2);
QCOMPARE(store.getHintsConst(id2), hints2);
// id1 and id3 should be removed
QVERIFY(store.getLinksConst(id1).isEmpty());
QVERIFY(store.getHintsConst(id1).isEmpty());
QVERIFY(store.getLinksConst(id3).isEmpty());
QVERIFY(store.getHintsConst(id3).isEmpty());
}
void testRemoveUnreferencedLinksEmpty()
{
TLinkStore store(10);
QStringList links;
links.append("command");
QStringList hints;
hints.append("hint");
int id1 = store.addLinks(links, hints);
int id2 = store.addLinks(links, hints);
// Empty reference set - all links should be removed
QSet<int> emptySet;
store.removeUnreferencedLinks(emptySet, nullptr);
QVERIFY(store.getLinksConst(id1).isEmpty());
QVERIFY(store.getHintsConst(id1).isEmpty());
QVERIFY(store.getLinksConst(id2).isEmpty());
QVERIFY(store.getHintsConst(id2).isEmpty());
}
void testRemoveUnreferencedLinksNone()
{
TLinkStore store(10);
QStringList links;
links.append("command");
QStringList hints;
hints.append("hint");
int id1 = store.addLinks(links, hints);
int id2 = store.addLinks(links, hints);
// Both links referenced - none should be removed
QSet<int> referencedIds;
referencedIds.insert(id1);
referencedIds.insert(id2);
store.removeUnreferencedLinks(referencedIds, nullptr);
QCOMPARE(store.getLinksConst(id1), links);
QCOMPARE(store.getHintsConst(id1), hints);
QCOMPARE(store.getLinksConst(id2), links);
QCOMPARE(store.getHintsConst(id2), hints);
}
void testRemoveUnreferencedLinksWithExpireNames()
{
TLinkStore store(10);
QStringList links;
links.append("command");
QStringList hints;
hints.append("hint");
int id1 = store.addLinks(links, hints, nullptr, QVector<int>(), "expire_group");
int id2 = store.addLinks(links, hints);
// Verify expire name is set for id1
QCOMPARE(store.getExpireName(id1), QString("expire_group"));
QVERIFY(store.getExpireName(id2).isEmpty());
// Simulate only id2 is still referenced in buffer
QSet<int> referencedIds;
referencedIds.insert(id2);
store.removeUnreferencedLinks(referencedIds, nullptr);
// id1 should be removed along with its expire name
QVERIFY(store.getLinksConst(id1).isEmpty());
QVERIFY(store.getHintsConst(id1).isEmpty());
QVERIFY(store.getExpireName(id1).isEmpty());
// id2 should still exist
QCOMPARE(store.getLinksConst(id2), links);
QCOMPARE(store.getHintsConst(id2), hints);
}
void testRemoveUnreferencedLinksWithLuaReferences()
{
TLinkStore store(10);
QStringList links;
links.append("command");
QStringList hints;
hints.append("hint");
QVector<int> luaRefs;
luaRefs.append(42);
int id1 = store.addLinks(links, hints, nullptr, luaRefs);
int id2 = store.addLinks(links, hints);
// Simulate only id2 is still referenced in buffer
QSet<int> referencedIds;
referencedIds.insert(id2);
store.removeUnreferencedLinks(referencedIds, nullptr);
// id1 should be removed (freeReference is a no-op when pH is nullptr)
QVERIFY(store.getLinksConst(id1).isEmpty());
QVERIFY(store.getHintsConst(id1).isEmpty());
// id2 should still exist
QCOMPARE(store.getLinksConst(id2), links);
QCOMPARE(store.getHintsConst(id2), hints);
}
void testStylingClearedOnIdReuse()
{
TLinkStore store(3);
QStringList links;
links.append("command");
QStringList hints;
hints.append("hint");
int id1 = store.addLinks(links, hints);
QCOMPARE(id1, 1);
Mudlet::HyperlinkStyling styling;
styling.hasCustomStyling = true;
styling.selection.group = "weapons";
styling.selection.value = "sword";
styling.selection.hasSelectionSettings = true;
store.setStyling(id1, styling);
QVERIFY(store.hasStyling(id1));
QCOMPARE(store.getLinkIdsByGroupValue("weapons", "sword"), QList<int>() << id1);
// Wrap the id counter around so id 1 is recycled by a link
// that provides no styling of its own
store.addLinks(links, hints);
store.addLinks(links, hints);
int recycledId = store.addLinks(links, hints);
QCOMPARE(recycledId, id1);
// The recycled id must not inherit the previous link's styling,
// nor remain findable under the old selection group/value
QVERIFY(!store.hasStyling(recycledId));
QVERIFY(store.getLinkIdsByGroupValue("weapons", "sword").isEmpty());
}
void cleanupTestCase() {}
};
#include "TLinkStoreTest.moc"
QTEST_MAIN(TLinkStoreTest)