diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index f46101bfa..5b3f13eb7 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -475,6 +475,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ElementsCollection/elementstreeview.h
${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.cpp
${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.h
+ ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp
+ ${QET_DIR}/sources/ElementsCollection/terminaluuids.h
${QET_DIR}/sources/ElementsCollection/xmlelementcollection.cpp
${QET_DIR}/sources/ElementsCollection/xmlelementcollection.h
${QET_DIR}/sources/ElementsCollection/xmlprojectelementcollectionitem.cpp
diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp
new file mode 100644
index 000000000..d19f831f8
--- /dev/null
+++ b/sources/ElementsCollection/terminaluuids.cpp
@@ -0,0 +1,89 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech 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.
+
+ QElectroTech 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 QElectroTech. If not, see .
+*/
+#include "terminaluuids.h"
+
+#include
+#include
+#include
+
+namespace {
+/**
+ @return the of the definition held by the embedded
+ collection element @p collection_element
+*/
+QList terminalsOf(const QDomElement &collection_element)
+{
+ QList terminals;
+ const QDomElement description = collection_element
+ .firstChildElement(QStringLiteral("definition"))
+ .firstChildElement(QStringLiteral("description"));
+ for (QDomElement t = description.firstChildElement(QStringLiteral("terminal"));
+ !t.isNull();
+ t = t.nextSiblingElement(QStringLiteral("terminal"))) {
+ terminals << t;
+ }
+ return terminals;
+}
+
+ //Place and orientation, "10" and "10.0" being the same place
+QString terminalPlace(const QDomElement &terminal)
+{
+ return QStringLiteral("%1|%2|%3")
+ .arg(QString::number(terminal.attribute(QStringLiteral("x")).toDouble()),
+ QString::number(terminal.attribute(QStringLiteral("y")).toDouble()),
+ terminal.attribute(QStringLiteral("orientation")));
+}
+}
+
+/**
+ @brief TerminalUuids::keep
+ Give each terminal of @p new_element the uuid of the terminal of
+ @p old_element at the same place and orientation. Both are
+ of an embedded collection.
+
+ A wire is saved against the uuids of the terminals it joins, and on
+ loading it is reattached to a terminal with that uuid or not at all.
+ The symbols already on the folios keep the terminals of the definition
+ they were built from, so when the project's definition is replaced by
+ one whose terminal uuids differ -- another copy of the same symbol, or
+ one saved by a different version of the collection -- every wire on
+ those symbols was lost the next time the project was opened.
+
+ Terminals that moved, and new ones, keep their own uuid. Where the old
+ definition has two terminals at one place, they are matched in order.
+*/
+void TerminalUuids::keep(const QDomElement &old_element, QDomElement &new_element)
+{
+ QHash old_uuids;
+ for (const QDomElement &t : terminalsOf(old_element)) {
+ const QString uuid = t.attribute(QStringLiteral("uuid"));
+ if (!uuid.isEmpty()) {
+ old_uuids[terminalPlace(t)] << uuid;
+ }
+ }
+ if (old_uuids.isEmpty()) {
+ return;
+ }
+
+ for (QDomElement t : terminalsOf(new_element)) {
+ QStringList &uuids = old_uuids[terminalPlace(t)];
+ if (!uuids.isEmpty()) {
+ t.setAttribute(QStringLiteral("uuid"), uuids.takeFirst());
+ }
+ }
+}
diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h
new file mode 100644
index 000000000..872acbdd7
--- /dev/null
+++ b/sources/ElementsCollection/terminaluuids.h
@@ -0,0 +1,28 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech 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.
+
+ QElectroTech 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 QElectroTech. If not, see .
+*/
+#ifndef TERMINALUUIDS_H
+#define TERMINALUUIDS_H
+
+class QDomElement;
+
+namespace TerminalUuids
+{
+ void keep(const QDomElement &old_element, QDomElement &new_element);
+}
+
+#endif // TERMINALUUIDS_H
diff --git a/sources/ElementsCollection/xmlelementcollection.cpp b/sources/ElementsCollection/xmlelementcollection.cpp
index b124b72ff..ee6a8b1be 100644
--- a/sources/ElementsCollection/xmlelementcollection.cpp
+++ b/sources/ElementsCollection/xmlelementcollection.cpp
@@ -21,6 +21,7 @@
#include "../qetproject.h"
#include "../qetxml.h"
#include "elementslocation.h"
+#include "terminaluuids.h"
/**
@brief XmlElementCollection::XmlElementCollection
@@ -914,6 +915,7 @@ ElementsLocation XmlElementCollection::copyElement(
% "/" % new_elmt_name);
bool removed = false;
if (!element.isNull()) {
+ TerminalUuids::keep(element, elmt_dom);
element.parentNode().removeChild(element);
removed = true;
}
diff --git a/sources/diagram.cpp b/sources/diagram.cpp
index 008fa9495..9ffaede29 100644
--- a/sources/diagram.cpp
+++ b/sources/diagram.cpp
@@ -773,6 +773,18 @@ QUuid Diagram::uuid()
return m_uuid;
}
+/**
+ @brief Diagram::wiresNotReconnected
+ @return one line per wire of the loaded file that was left out because
+ a terminal it joins could not be found, for example after the symbol's
+ definition in the project was replaced by one whose terminals differ.
+ Empty for a folio that loaded every wire.
+*/
+QStringList Diagram::wiresNotReconnected() const
+{
+ return m_wires_not_reconnected;
+}
+
/**
@brief Diagram::uuidUsedByOtherDiagram
A hand-edited or merged project file can contain two folios with the same
@@ -1837,6 +1849,8 @@ bool Diagram::fromXml(QDomElement &document,
}
// Load conductor
+ if (consider_informations)
+ m_wires_not_reconnected.clear();
QList added_conductors;
for (auto f : QET::findInDomElement(root,
QStringLiteral("conductors"),
@@ -1849,6 +1863,33 @@ bool Diagram::fromXml(QDomElement &document,
Terminal* p1 = findTerminal(1, f, table_adr_id, added_elements);
Terminal* p2 = findTerminal(2, f, table_adr_id, added_elements);
+ //Keep a trace of the wire, it will be missing from the next save
+ if ((!p1 || !p2) && consider_informations)
+ {
+ //The symbol's label, else its name. For an end not found,
+ //only the uuid form of a wire says which symbol it is on.
+ auto end_label = [&f, &added_elements](const QString &index,
+ Terminal *found) {
+ Element *element = found ? found->parentElement() : nullptr;
+ const QUuid uuid(f.attribute(QStringLiteral("element") + index));
+ for (int i = 0 ; !element && !uuid.isNull()
+ && i < added_elements.size() ; ++i) {
+ if (added_elements.at(i)->uuid() == uuid)
+ element = added_elements.at(i);
+ }
+ if (!element)
+ return QStringLiteral("?");
+ const QString label = element->actualLabel();
+ return label.isEmpty() ? element->name() : label;
+ };
+ QString wire = QStringLiteral("%1 - %2").arg(end_label(QStringLiteral("1"), p1),
+ end_label(QStringLiteral("2"), p2));
+ const QString num = f.attribute(QStringLiteral("num"));
+ if (!num.isEmpty() && num != QLatin1String("_"))
+ wire += QStringLiteral(" (%1)").arg(num);
+ m_wires_not_reconnected << wire;
+ }
+
if (p1 && p2 && p1 != p2)
{
Conductor *c = new Conductor(p1, p2);
@@ -1884,6 +1925,12 @@ bool Diagram::fromXml(QDomElement &document,
delete c;
}
}
+ if (!m_wires_not_reconnected.isEmpty()) {
+ qWarning().noquote() << "Diagram::fromXml():"
+ << m_wires_not_reconnected.size()
+ << "wire(s) not loaded, a terminal they join was not found:"
+ << m_wires_not_reconnected.join(QStringLiteral(", "));
+ }
//Filling of falculatory lists
if (content_ptr) {
diff --git a/sources/diagram.h b/sources/diagram.h
index 83d408835..96299d036 100644
--- a/sources/diagram.h
+++ b/sources/diagram.h
@@ -148,7 +148,10 @@ class Diagram : public QGraphicsScene
bool uuidUsedByOtherDiagram(const QUuid &uuid) const;
QUuid derivedUuid(const QDomElement &root, const QString &reason) const;
-
+
+ //Wires of the loaded file whose ends could not be found
+ QStringList m_wires_not_reconnected;
+
// METHODS
protected:
void drawBackground(QPainter *, const QRectF &) override;
@@ -171,6 +174,7 @@ class Diagram : public QGraphicsScene
void correctTextPos(Element* elmt);
void restoreText(Element* elmt);
QUuid uuid();
+ QStringList wiresNotReconnected() const;
void setEventInterface (DiagramEventInterface *event_interface);
void clearEventInterface();
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index 80d52f4dd..7cb805bc3 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -1672,6 +1672,34 @@ bool QETDiagramEditor::openAndAddProject(
);
}
+ //Report wires left out of the load because a terminal they join
+ //was not found: they would otherwise vanish on the next save
+ //without the user ever being told.
+ QStringList lost_wires;
+ for (Diagram *diagram : project->diagrams()) {
+ for (const QString &wire : diagram->wiresNotReconnected()) {
+ lost_wires << tr("Folio %1 : %2").arg(diagram->folioIndex() + 1).arg(wire);
+ }
+ }
+ if (interactive && !lost_wires.isEmpty())
+ {
+ QMessageBox box(QMessageBox::Warning,
+ tr("Conducteurs non chargés", "message box title"),
+ tr("%n conducteur(s) n'ont pas pu être reliés à leurs"
+ " bornes et n'ont pas été chargés. La définition de"
+ " l'élément dans le projet a probablement été remplacée"
+ " par une autre dont les bornes diffèrent.\n\n"
+ "Si vous enregistrez le projet, ces conducteurs"
+ " disparaîtront du fichier. Fermez-le sans enregistrer pour"
+ " conserver le fichier tel quel.",
+ "message box content",
+ lost_wires.size()),
+ QMessageBox::Ok,
+ this);
+ box.setDetailedText(lost_wires.join(QLatin1Char('\n')));
+ box.exec();
+ }
+
BackupDialog backup_dialog(this);
if (backup_dialog.exec() == QDialog::Accepted)
{
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index e2fde811c..9656c557f 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -385,6 +385,20 @@ target_compile_definitions(tst_resaveunchanged PRIVATE
"QET_TEST_BINARY_PATH=\"$\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
+# Replacing a symbol's definition in a project keeps its terminal uuids, so
+# the wires saved against them still load: TerminalUuids::keep() on its own,
+# then the real binary's --info on an example whose symbols were replaced.
+add_executable(
+ tst_terminaluuids
+ tst_terminaluuids.cpp
+ ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp)
+add_test(NAME tst_terminaluuids COMMAND tst_terminaluuids)
+add_dependencies(tst_terminaluuids qelectrotech)
+target_link_libraries(tst_terminaluuids PRIVATE Qt::Test Qt::Xml)
+target_compile_definitions(tst_terminaluuids PRIVATE
+ "QET_TEST_BINARY_PATH=\"$\""
+ "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
+
# DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml
# for element definitions in the collection) give the same values: stray
# spaces trimmed, accents kept.
diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp
new file mode 100644
index 000000000..e641f3c57
--- /dev/null
+++ b/tests/qttest/tst_terminaluuids.cpp
@@ -0,0 +1,186 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+#include "../../sources/ElementsCollection/terminaluuids.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Replacing a symbol's definition in a project must not cost the wires on
+// it. A wire is saved against the uuids of the terminals it joins and is
+// reattached on load to a terminal with that uuid, or not at all; the
+// replacement is another copy of the symbol, whose terminals usually carry
+// other uuids. TerminalUuids::keep() carries the old uuids over.
+namespace {
+
+QDomElement symbol(QDomDocument &doc, const QList &terminals)
+{
+ QDomElement element = doc.createElement(QStringLiteral("element"));
+ QDomElement definition = doc.createElement(QStringLiteral("definition"));
+ QDomElement description = doc.createElement(QStringLiteral("description"));
+ element.appendChild(definition);
+ definition.appendChild(description);
+ for (const QStringList &t : terminals) {
+ QDomElement terminal = doc.createElement(QStringLiteral("terminal"));
+ terminal.setAttribute(QStringLiteral("x"), t.at(0));
+ terminal.setAttribute(QStringLiteral("y"), t.at(1));
+ terminal.setAttribute(QStringLiteral("orientation"), t.at(2));
+ if (t.size() > 3)
+ terminal.setAttribute(QStringLiteral("uuid"), t.at(3));
+ description.appendChild(terminal);
+ }
+ return element;
+}
+
+QStringList uuids(const QDomElement &element)
+{
+ QStringList out;
+ const QDomNodeList nodes = element.elementsByTagName(QStringLiteral("terminal"));
+ for (int i = 0; i < nodes.size(); ++i)
+ out << nodes.at(i).toElement().attribute(QStringLiteral("uuid"));
+ return out;
+}
+
+QList embeddedSymbols(const QDomDocument &doc)
+{
+ QList out;
+ const QDomNodeList nodes = doc.documentElement()
+ .firstChildElement(QStringLiteral("collection"))
+ .elementsByTagName(QStringLiteral("element"));
+ for (int i = 0; i < nodes.size(); ++i)
+ out << nodes.at(i).toElement();
+ return out;
+}
+
+}
+
+class tst_terminaluuids : public QObject
+{
+ Q_OBJECT
+
+ QTemporaryDir m_dir;
+ int m_run = 0;
+
+ // --info on @p project in a sandbox of its own; returns the number of
+ // wires loaded and puts what was written on stderr in @p log.
+ int loadedWires(const QString &project, QString *log)
+ {
+ const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
+ QDir().mkpath(home);
+ 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("--info"), project});
+ if (!proc.waitForFinished(120000) || proc.exitCode() != 0)
+ return -1;
+ *log = QString::fromUtf8(proc.readAllStandardError());
+ const QByteArray out = proc.readAllStandardOutput();
+ const QJsonDocument json = QJsonDocument::fromJson(out.mid(out.indexOf('{')));
+ return json.object().value(QStringLiteral("conductors")).toInt(-1);
+ }
+
+ // 2612_ats_singlephase.qet with each embedded symbol replaced by a copy
+ // whose terminals carry new uuids, passed through keep() or not.
+ QString replacedSymbols(bool keep_uuids)
+ {
+ QFile in(QStringLiteral(QET_EXAMPLES_DIR "/2612_ats_singlephase.qet"));
+ if (!in.open(QIODevice::ReadOnly)) return {};
+ QDomDocument doc;
+ if (!doc.setContent(&in)) return {};
+ for (const QDomElement &old_symbol : embeddedSymbols(doc)) {
+ QDomElement new_symbol = old_symbol.cloneNode().toElement();
+ const QDomNodeList terminals = new_symbol.elementsByTagName(QStringLiteral("terminal"));
+ for (int i = 0; i < terminals.size(); ++i)
+ terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"),
+ QUuid::createUuid().toString());
+ if (keep_uuids)
+ TerminalUuids::keep(old_symbol, new_symbol);
+ old_symbol.parentNode().replaceChild(new_symbol, old_symbol);
+ }
+ const QString path = m_dir.filePath(keep_uuids ? QStringLiteral("kept.qet")
+ : QStringLiteral("lost.qet"));
+ QFile out(path);
+ if (!out.open(QIODevice::WriteOnly)) return {};
+ out.write(doc.toByteArray());
+ return path;
+ }
+
+private slots:
+ void samePlaceTakesOldUuid()
+ {
+ QDomDocument doc;
+ const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"},
+ {"20", "10", "s", "{b}"}});
+ QDomElement new_symbol = symbol(doc, {{"20.0", "10", "s", "{y}"},
+ {"0", "10.0", "n", "{x}"}});
+ TerminalUuids::keep(old_symbol, new_symbol);
+ QCOMPARE(uuids(new_symbol), (QStringList{"{b}", "{a}"}));
+ }
+
+ void movedOrNewTerminalKeepsItsOwn()
+ {
+ QDomDocument doc;
+ const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}});
+ QDomElement new_symbol = symbol(doc, {{"0", "10", "e", "{x}"},
+ {"0", "20", "n", "{y}"},
+ {"0", "30", "n"}});
+ TerminalUuids::keep(old_symbol, new_symbol);
+ QCOMPARE(uuids(new_symbol), (QStringList{"{x}", "{y}", ""}));
+ }
+
+ void oldWithoutUuidChangesNothing()
+ {
+ QDomDocument doc;
+ const QDomElement old_symbol = symbol(doc, {{"0", "10", "n"}});
+ QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"}});
+ TerminalUuids::keep(old_symbol, new_symbol);
+ QCOMPARE(uuids(new_symbol), (QStringList{"{x}"}));
+ }
+
+ void twoAtOnePlaceMatchedInOrder()
+ {
+ QDomDocument doc;
+ const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"},
+ {"0", "10", "n", "{b}"}});
+ QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"},
+ {"0", "10", "n", "{y}"},
+ {"0", "10", "n", "{z}"}});
+ TerminalUuids::keep(old_symbol, new_symbol);
+ QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{b}", "{z}"}));
+ }
+
+ // The real loader, on a project whose 131 wires are saved against
+ // terminal uuids: replaced symbols lose 119 of them unless keep() ran,
+ // and the ones lost are reported.
+ void wiresSurviveReplacedSymbols()
+ {
+ QVERIFY(m_dir.isValid());
+ QString log;
+
+ const QString lost = replacedSymbols(false);
+ QVERIFY(!lost.isEmpty());
+ QCOMPARE(loadedWires(lost, &log), 12);
+ QVERIFY2(log.contains(QStringLiteral("55 wire(s) not loaded"))
+ && log.contains(QStringLiteral("64 wire(s) not loaded")),
+ "the lost wires were not reported");
+
+ const QString kept = replacedSymbols(true);
+ QVERIFY(!kept.isEmpty());
+ QCOMPARE(loadedWires(kept, &log), 131);
+ QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost");
+ }
+};
+
+QTEST_APPLESS_MAIN(tst_terminaluuids)
+
+#include "tst_terminaluuids.moc"