Give wires saved without a uuid a lasting one, from what they connect

A wire saved without a uuid got a random one on every load, never saved
(#754): it had no identity from one session to the next, so a script
could only name it as "the wire on terminal N of symbol X", and a
comparison of two versions could not tell a moved wire from a new one.

When a folio is loaded, such a wire now gets a UUID v5 derived from its
two ends -- the symbol and terminal at each, sorted so the direction it
was drawn in does not matter -- and it is written on save. Never its
place in the file or its folio's index: inserting or moving a folio, or
saving the wires in another order, does not change it. Once saved the
uuid no longer depends on the ends, so re-connecting the wire keeps it;
QETProject::derivedItemUuid() never hands out a uuid the file already
carries, so a wire later drawn on the ends it left gets another one.
Wires that have a uuid keep it; a paste still renews them.

The 24 example projects: 3,189 wires, none with a uuid before, all 3,189
after one save, none lost, no uuid used twice in any project; two saves
of the same file are identical, and a second save keeps every wire's
uuid. Discussion #1103 has the measurements behind the recipe.

tst_derivedwireuuid runs --resave on a fixture naming ends by uuid and on
examples/tremie_vibrante.qet (ends by terminal number): every wire gets a
distinct uuid, the same on every load, read back after a save, kept per
wire when a folio is inserted, the wires are reordered or a wire is drawn
the other way, and a newcomer on a re-connected wire's old ends gets
another uuid. Without this change 14 of the 18 fail; with the uuid taken
from folio index and file order instead, the folio-insert and reorder
tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-28 20:57:02 +13:00
parent e12410e439
commit b3a4e30010
4 changed files with 344 additions and 0 deletions
+22
View File
@@ -1825,6 +1825,28 @@ bool Diagram::fromXml(QDomElement &document,
{
addItem(c);
c -> fromXml(f);
//A wire saved without a uuid got a random one that was
//never saved (#754), so it had no identity from one
//session to the next. Derive it from what it connects:
//the symbol and terminal at each end, sorted so the
//direction it was drawn in does not matter. Never its
//place in the file or its folio's index, so inserting or
//moving a folio, or saving the wires in another order,
//does not change it. It is saved from now on, so
//re-connecting the wire later keeps it; derivedItemUuid()
//never hands out a uuid the file already carries, so a
//wire later drawn on the ends it left gets another one.
if (consider_informations && m_project
&& QUuid(f.attribute(QStringLiteral("uuid"))).isNull()) {
auto end = [](const Terminal *t) {
return t->parentElement()->uuid().toString()
+ QLatin1Char('/') + t->stableUuid().toString();
};
QStringList ends{end(p1), end(p2)};
ends.sort();
c->setUuid(m_project->derivedItemUuid(QStringLiteral("conductor"),
ends.join(QLatin1Char('\n'))));
}
added_conductors << c;
}
else
+1
View File
@@ -80,6 +80,7 @@ class Conductor : public QGraphicsObject
ConductorTextItem *textItem() const;
QUuid uuid() const {return m_uuid;}
void newUuid() {m_uuid = QUuid::createUuid(); m_persist_uuid = true;} //create new uuid for this conductor
void setUuid(const QUuid &uuid) {m_uuid = uuid; m_persist_uuid = true;} //saved from now on
void updatePath(const QRectF & = QRectF());
//This method do nothing, it's only made to be used with Q_PROPERTY
+14
View File
@@ -340,3 +340,17 @@ add_dependencies(tst_derivedsymboluuid qelectrotech)
target_link_libraries(tst_derivedsymboluuid PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_derivedsymboluuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# A wire saved without a uuid gets one derived from its two ends: the same
# on every load, saved, and unchanged by inserting a folio, reordering the
# wires or drawing a wire the other way. Runs the real binary's --resave on
# a fixture and on examples/tremie_vibrante.qet (ends by terminal number).
add_executable(
tst_derivedwireuuid
tst_derivedwireuuid.cpp)
add_test(NAME tst_derivedwireuuid COMMAND tst_derivedwireuuid)
add_dependencies(tst_derivedwireuuid qelectrotech)
target_link_libraries(tst_derivedwireuuid PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_derivedwireuuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
+307
View File
@@ -0,0 +1,307 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QHash>
#include <QMap>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QUuid>
// A wire saved without a uuid must get one that is the same on every load,
// is written on save, and survives the edits people make before that save:
// inserting a folio, saving the wires in another order, drawing the wire
// the other way round. Until this was fixed it got a random uuid on every
// load, never saved (#754).
//
// Runs the real binary (--resave) on two fixtures -- one whose wires name
// their ends by symbol and terminal uuid, one older file naming them by
// terminal number -- and reads the wires' uuids back from the saved file.
namespace {
QDomDocument load(const QString &path)
{
QDomDocument doc;
QFile file(path);
if (file.open(QIODevice::ReadOnly))
doc.setContent(&file);
return doc;
}
QList<QDomElement> diagrams(const QDomDocument &doc)
{
QList<QDomElement> out;
for (QDomElement d = doc.documentElement().firstChildElement(QStringLiteral("diagram"));
!d.isNull(); d = d.nextSiblingElement(QStringLiteral("diagram")))
out << d;
return out;
}
QList<QDomElement> wires(const QDomElement &diagram)
{
QList<QDomElement> out;
const QDomElement block = diagram.firstChildElement(QStringLiteral("conductors"));
for (QDomElement c = block.firstChildElement(QStringLiteral("conductor"));
!c.isNull(); c = c.nextSiblingElement(QStringLiteral("conductor")))
out << c;
return out;
}
// Every wire's uuid in the file, sorted: equal lists mean every wire kept
// its uuid.
QStringList wireUuids(const QDomDocument &doc)
{
QStringList out;
for (const QDomElement &d : diagrams(doc))
for (const QDomElement &c : wires(d))
out << c.attribute(QStringLiteral("uuid"));
out.sort();
return out;
}
// Which wire carries which uuid: each wire keyed by its two ends as the
// saved file names them, sorted. An older file names an end by terminal
// number, resolved here to the symbol and the terminal's place on it.
QMap<QString, QString> wireMap(const QDomDocument &doc)
{
QMap<QString, QString> out;
for (const QDomElement &d : diagrams(doc)) {
QHash<QString, QString> by_number;
const QDomNodeList symbols = d.firstChildElement(QStringLiteral("elements"))
.elementsByTagName(QStringLiteral("element"));
for (int i = 0; i < symbols.size(); ++i) {
const QDomElement e = symbols.at(i).toElement();
const QDomNodeList ts = e.elementsByTagName(QStringLiteral("terminal"));
for (int j = 0; j < ts.size(); ++j) {
const QDomElement t = ts.at(j).toElement();
by_number.insert(t.attribute(QStringLiteral("id")),
e.attribute(QStringLiteral("uuid")) + QLatin1Char('/')
+ t.attribute(QStringLiteral("x")) + QLatin1Char(',')
+ t.attribute(QStringLiteral("y")));
}
}
for (const QDomElement &c : wires(d)) {
QStringList ends;
for (const QString n : {QStringLiteral("1"), QStringLiteral("2")}) {
const QString element = c.attribute(QStringLiteral("element") + n);
const QString terminal = c.attribute(QStringLiteral("terminal") + n);
ends << (element.isEmpty() ? by_number.value(terminal, QStringLiteral("?"))
: element + QLatin1Char('/') + terminal);
}
ends.sort();
out.insert(ends.join(QLatin1Char('|')), c.attribute(QStringLiteral("uuid")));
}
}
return out;
}
} // namespace
class tst_derivedwireuuid : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
// Save @p doc, run --resave on it in a sandbox of its own (so a running
// QElectroTech cannot answer instead), and return what was saved.
QDomDocument resave(const QDomDocument &doc)
{
const QString in = m_dir.filePath(QStringLiteral("in%1.qet").arg(m_run));
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QFile f(in);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(doc.toByteArray());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0) return {};
return load(out);
}
// The fixture as an older QElectroTech saved it: wires without uuids.
QDomDocument fixture()
{
QFETCH(QString, path);
QDomDocument doc = load(path);
for (const QDomElement &d : diagrams(doc))
for (QDomElement c : wires(d))
c.removeAttribute(QStringLiteral("uuid"));
return doc;
}
// The saved uuids of the unedited fixture, checked for sanity.
QStringList reference()
{
QFETCH(int, count);
const QStringList ids = wireUuids(resave(fixture()));
if (ids.size() != count) return {};
for (const QString &u : ids)
if (QUuid(u).isNull()) return {};
return ids;
}
void fixtures()
{
QTest::addColumn<QString>("path");
QTest::addColumn<int>("count");
QTest::newRow("ends by uuid")
<< QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet") << 7;
QTest::newRow("ends by terminal number (older file)")
<< QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet") << 77;
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void savedAndUnique_data() { fixtures(); }
void savedAndUnique()
{
QFETCH(int, count);
const QStringList ids = reference();
QCOMPARE(ids.size(), count); // every wire has one
QCOMPARE(QSet<QString>(ids.begin(), ids.end()).size(), count); // all different
QCOMPARE(wireMap(resave(fixture())).size(), count); // the test's wire key is unique too
}
void sameOnEveryLoad_data() { fixtures(); }
void sameOnEveryLoad()
{
const QStringList a = reference();
QVERIFY(!a.isEmpty());
QCOMPARE(wireUuids(resave(fixture())), a);
}
void savedUuidIsReadBack_data() { fixtures(); }
void savedUuidIsReadBack()
{
const QDomDocument once = resave(fixture());
QVERIFY(!wireUuids(once).isEmpty());
QCOMPARE(wireUuids(resave(once)), wireUuids(once));
}
void insertingAFolioChangesNothing_data() { fixtures(); }
void insertingAFolioChangesNothing()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
const QDomElement first = diagrams(doc).first();
QDomElement blank = first.cloneNode(false).toElement();
blank.setAttribute(QStringLiteral("title"), QStringLiteral("new"));
blank.removeAttribute(QStringLiteral("uuid"));
blank.appendChild(doc.createElement(QStringLiteral("elements")));
doc.documentElement().insertBefore(blank, first);
QCOMPARE(wireMap(resave(doc)), before);
}
void wireOrderDoesNotMatter_data() { fixtures(); }
void wireOrderDoesNotMatter()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
for (const QDomElement &d : diagrams(doc)) {
QDomElement block = d.firstChildElement(QStringLiteral("conductors"));
const QList<QDomElement> ws = wires(d);
for (const QDomElement &w : ws)
block.removeChild(w);
for (auto it = ws.crbegin(); it != ws.crend(); ++it) // reversed
block.appendChild(*it);
}
QCOMPARE(wireMap(resave(doc)), before);
}
void directionDoesNotMatter_data() { fixtures(); }
void directionDoesNotMatter()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
static const QRegularExpression pair(
QStringLiteral("^(element|terminal|terminalname)([12])(.*)$"));
for (const QDomElement &d : diagrams(doc)) {
for (QDomElement w : wires(d)) {
const QDomNamedNodeMap attrs = w.attributes();
QHash<QString, QString> swapped;
for (int i = 0; i < attrs.size(); ++i) {
const QString name = attrs.item(i).nodeName();
const auto m = pair.match(name);
if (m.hasMatch())
swapped.insert(m.captured(1) + (m.captured(2) == QLatin1String("1")
? QStringLiteral("2") : QStringLiteral("1"))
+ m.captured(3),
attrs.item(i).nodeValue());
}
for (auto it = swapped.cbegin(); it != swapped.cend(); ++it)
w.setAttribute(it.key(), it.value());
}
}
QCOMPARE(wireMap(resave(doc)), before);
}
// A wire keeps its uuid once saved, even when re-connected. A wire saved
// without a uuid that later turns up on the ends it left (a hand edit, an
// older version, another tool) would derive the same uuid: it must get
// another one instead.
void newcomerOnAReconnectedWiresEndsGetsAnotherUuid_data() { fixtures(); }
void newcomerOnAReconnectedWiresEndsGetsAnotherUuid()
{
QFETCH(int, count);
QDomDocument doc = resave(fixture());
QVERIFY(!wireUuids(doc).isEmpty());
const QList<QDomElement> ws = wires(diagrams(doc).first());
QVERIFY(ws.size() >= 2);
QDomElement x = ws.at(0);
const QDomElement y = ws.at(1);
QDomElement newcomer = x.cloneNode(true).toElement(); // x's old ends
newcomer.removeAttribute(QStringLiteral("uuid"));
// re-connect x's second end to y's second end, keeping x's uuid
static const QRegularExpression end2(QStringLiteral("^(element|terminal|terminalname)2"));
const QDomNamedNodeMap attrs = y.attributes();
for (int i = 0; i < attrs.size(); ++i)
if (end2.match(attrs.item(i).nodeName()).hasMatch())
x.setAttribute(attrs.item(i).nodeName(), attrs.item(i).nodeValue());
x.parentNode().appendChild(newcomer);
const QStringList after = wireUuids(resave(doc));
QCOMPARE(after.size(), count + 1);
QCOMPARE(QSet<QString>(after.begin(), after.end()).size(), count + 1);
}
// QElectroTech refuses a second wire between two terminals already joined
// (Terminal::canBeLinkedTo()), so two wires on one folio never share both
// ends. A file that has one anyway must still load to the same uuids.
void doubledWireIsDroppedAndOthersKeepTheirs_data() { fixtures(); }
void doubledWireIsDroppedAndOthersKeepTheirs()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
QDomElement w = wires(diagrams(doc).first()).first();
w.parentNode().appendChild(w.cloneNode(true));
QCOMPARE(wireMap(resave(doc)), before);
}
};
QTEST_APPLESS_MAIN(tst_derivedwireuuid)
#include "tst_derivedwireuuid.moc"