diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp
index 9f5b0e16b..2927e6115 100644
--- a/sources/cli_export.cpp
+++ b/sources/cli_export.cpp
@@ -24,6 +24,7 @@
#include "dataBase/projectdatabase.h"
#include "diagram.h"
#include "diagramcontext.h"
+#include "editor/terminalnamecheck.h"
#include "dxfexport.h"
#include "exportproperties.h"
#include "pdf_links.h"
@@ -513,7 +514,20 @@ int checkOneElement(const QString &path)
return 2;
}
- const int terminals = root.elementsByTagName("terminal").count();
+ const QDomNodeList terminal_nodes = root.elementsByTagName("terminal");
+ const int terminals = terminal_nodes.count();
+ QStringList terminal_names;
+ for (int i = 0; i < terminals; ++i)
+ terminal_names << terminal_nodes.at(i).toElement().attribute("name");
+
+ // Two terminals with one name cannot be told apart in a wiring list
+ // (IEC 61666), the same rule the element editor applies on save.
+ const auto repeated = TerminalNameCheck::repeatedNames(terminal_names);
+ if (!repeated.isEmpty()) {
+ out << "FAIL " << path << " (repeated terminal names: "
+ << TerminalNameCheck::describe(repeated) << ")\n";
+ return 2;
+ }
// Negative dimensions are malformed but QET still loads them; surface as a
// warning rather than a failure so this agrees with QET's own loader.
@@ -528,6 +542,15 @@ int checkOneElement(const QString &path)
return 1;
}
+ const QString type = root.attribute("link_type");
+ const int unnamed = TerminalNameCheck::unnamedCount(terminal_names);
+ if (unnamed && !type.endsWith("_report")
+ && type != "conductor_definition" && type != "thumbnail") {
+ out << "WARN " << path << " (" << unnamed << " of " << terminals
+ << " terminals have no name)\n";
+ return 1;
+ }
+
out << "OK " << path << " (" << terminals << " terminals)\n";
return 0;
}
diff --git a/sources/editor/terminalnamecheck.h b/sources/editor/terminalnamecheck.h
new file mode 100644
index 000000000..8861d444f
--- /dev/null
+++ b/sources/editor/terminalnamecheck.h
@@ -0,0 +1,91 @@
+/*
+ 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 TERMINALNAMECHECK_H
+#define TERMINALNAMECHECK_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ The terminal name check run when an element is saved, and by
+ --check-elements. IEC 61666 requires every terminal to be identified
+ unambiguously within its object: two terminals of one element must not
+ share a name, and a terminal should have one.
+
+ Names are compared after trimming spaces at both ends, and are case
+ sensitive ("n" and "N" are different names).
+*/
+namespace TerminalNameCheck
+{
+ /// QSettings key: false turns the check off in the element editor.
+ inline const QString settings_key{QStringLiteral("elementeditor/check-terminal-names")};
+
+ /// The names used by more than one terminal, with how many terminals
+ /// use each, in the order the names first appear. Unnamed terminals
+ /// are not counted here.
+ inline QList> repeatedNames(const QStringList &names)
+ {
+ QHash count;
+ QStringList order;
+ for (const QString &raw : names) {
+ const QString name = raw.trimmed();
+ if (name.isEmpty()) {
+ continue;
+ }
+ if (!count.contains(name)) {
+ order << name;
+ }
+ ++count[name];
+ }
+
+ QList> repeated;
+ for (const QString &name : order) {
+ if (count.value(name) > 1) {
+ repeated << qMakePair(name, count.value(name));
+ }
+ }
+ return repeated;
+ }
+
+ /// The number of terminals without a name.
+ inline int unnamedCount(const QStringList &names)
+ {
+ int unnamed = 0;
+ for (const QString &name : names) {
+ if (name.trimmed().isEmpty()) {
+ ++unnamed;
+ }
+ }
+ return unnamed;
+ }
+
+ /// "N ×3, L ×2": the repeated names, for messages.
+ inline QString describe(const QList> &repeated)
+ {
+ QStringList parts;
+ for (const auto &entry : repeated) {
+ parts << QStringLiteral("%1 ×%2").arg(entry.first, QString::number(entry.second));
+ }
+ return parts.join(QStringLiteral(", "));
+ }
+}
+
+#endif // TERMINALNAMECHECK_H
diff --git a/sources/editor/ui/qetelementeditor.cpp b/sources/editor/ui/qetelementeditor.cpp
index 5428ec1b9..c13513665 100644
--- a/sources/editor/ui/qetelementeditor.cpp
+++ b/sources/editor/ui/qetelementeditor.cpp
@@ -40,6 +40,7 @@
#include "../esevent/eseventadddynamictextfield.h"
#include "../../elementdialog.h"
#include "../graphicspart/partterminal.h"
+#include "../terminalnamecheck.h"
#include "../arceditor.h"
#include "ellipseeditor.h"
#include "lineeditor.h"
@@ -835,6 +836,53 @@ bool QETElementEditor::checkElement()
}
}
+ // Check terminal names: repeated names are an error, missing names a warning
+ if (QSettings().value(TerminalNameCheck::settings_key, true).toBool())
+ {
+ QList terminals;
+ QStringList names;
+ for (auto qgi : m_elmt_scene -> items()) {
+ if (auto terminal = qgraphicsitem_cast(qgi)) {
+ terminals << terminal;
+ names << terminal -> terminalName();
+ }
+ }
+
+ const auto repeated = TerminalNameCheck::repeatedNames(names);
+ if (!repeated.isEmpty())
+ {
+ errors << qMakePair (tr("Noms de bornes en double"),
+ tr("
Erreur :"
+ "
Plusieurs bornes portent le même nom : %1."
+ "
Solution :"
+ "
Donner un nom unique à chaque borne, par exemple N.1 et N.2."
+ " Les bornes concernées sont sélectionnées.")
+ .arg(TerminalNameCheck::describe(repeated).toHtmlEscaped()));
+
+ m_elmt_scene -> clearSelection();
+ for (auto terminal : terminals) {
+ for (const auto &entry : repeated) {
+ if (terminal -> terminalName().trimmed() == entry.first) {
+ terminal -> setSelected(true);
+ }
+ }
+ }
+ }
+
+ const int unnamed = TerminalNameCheck::unnamedCount(names);
+ if (unnamed &&
+ !(m_elmt_scene->elementData().m_type & ElementData::AllReport) &&
+ m_elmt_scene->elementData().m_type != ElementData::ConductorDefinition &&
+ m_elmt_scene->elementData().m_type != ElementData::Thumbnail)
+ {
+ warnings << qMakePair (tr("Bornes sans nom"),
+ tr("
%n borne(s) sans nom. Sans noms de bornes uniques,"
+ " la liste de câblage (qui relie quoi à quoi) ne peut pas"
+ " désigner chaque borne, et ne peut donc pas servir à"
+ " câbler l'armoire en atelier.", "", unnamed));
+ }
+ }
+
if (!errors.count() && !warnings.count()) {
return(true);
}
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 63bea4a11..af63fba0e 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -25,6 +25,7 @@
#include "../../utils/qetutils.h"
#include "../../qetmessagebox.h"
#include "../../textgrid.h"
+#include "../../editor/terminalnamecheck.h"
#include "../nokde/kcolorbutton.h"
#include
#include
@@ -174,6 +175,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->MaxPartsElementEditorList_sb->setValue(settings.value("elementeditor/max-parts-element-editor-list", 200).toInt());
ui->ElementEditor_Grid_PointSize_min_sb->setValue(settings.value("elementeditor/grid_pointsize_min", 1).toInt());
ui->ElementEditor_Grid_PointSize_max_sb->setValue(settings.value("elementeditor/grid_pointsize_max", 1).toInt());
+ ui->m_check_terminal_names_cb->setChecked(settings.value(TerminalNameCheck::settings_key, true).toBool());
QString path = settings.value("elements-collections/common-collection-path", "default").toString();
if (path != "default")
@@ -286,6 +288,7 @@ void GeneralConfigurationPage::applyConf()
settings.setValue("elementeditor/max-parts-element-editor-list", ui->MaxPartsElementEditorList_sb->value());
settings.setValue("elementeditor/grid_pointsize_min", ui->ElementEditor_Grid_PointSize_min_sb->value());
settings.setValue("elementeditor/grid_pointsize_max", ui->ElementEditor_Grid_PointSize_max_sb->value());
+ settings.setValue(TerminalNameCheck::settings_key, ui->m_check_terminal_names_cb->isChecked());
//DIAGRAM VIEW
settings.setValue("diagramview/gestures", ui->m_use_gesture_trackpad->isChecked());
diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui
index e0251641e..50d46dd6a 100644
--- a/sources/ui/configpage/generalconfigurationpage.ui
+++ b/sources/ui/configpage/generalconfigurationpage.ui
@@ -1240,6 +1240,19 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
+ -
+
+
+ À l'enregistrement d'un élément : refuser deux bornes portant le même nom, et signaler les bornes sans nom (IEC 61666).
+
+
+ Vérifier les noms des bornes à l'enregistrement
+
+
+ true
+
+
+
-
@@ -1275,6 +1288,7 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
m_custom_tbt_path_cb
m_highlight_integrated_elements
m_default_elements_info
+ m_check_terminal_names_cb
m_lang_cb
m_dyn_text_font_pb
m_dyn_text_rotation_sb
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index c8c159b15..3288339a3 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -111,6 +111,13 @@ add_test(NAME tst_alignment COMMAND tst_alignment)
target_include_directories(tst_alignment PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_alignment PRIVATE Qt::Test)
+# terminalnamecheck.h is header-only: the terminal name rule applied by the
+# element editor on save and by --check-elements.
+add_executable(tst_terminalnamecheck tst_terminalnamecheck.cpp)
+add_test(NAME tst_terminalnamecheck COMMAND tst_terminalnamecheck)
+target_include_directories(tst_terminalnamecheck PRIVATE ${QET_DIR}/sources)
+target_link_libraries(tst_terminalnamecheck PRIVATE Qt::Test)
+
# itemgroups.cpp keeps group membership and the selection rule apart from
# Diagram, so it is tested here on a plain scene of rectangles.
add_executable(tst_itemgroups tst_itemgroups.cpp ${QET_DIR}/sources/itemgroups.cpp)
diff --git a/tests/qttest/tst_terminalnamecheck.cpp b/tests/qttest/tst_terminalnamecheck.cpp
new file mode 100644
index 000000000..cb4a084bf
--- /dev/null
+++ b/tests/qttest/tst_terminalnamecheck.cpp
@@ -0,0 +1,55 @@
+#include
+
+#include "editor/terminalnamecheck.h"
+
+class tst_terminalnamecheck : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void uniqueNamesPass()
+ {
+ QVERIFY(TerminalNameCheck::repeatedNames({"A1", "A2", "13", "14"}).isEmpty());
+ }
+
+ // Shelly Pro 2PM in the shipped collection: three terminals named N.
+ void repeatedNamesAreCounted()
+ {
+ const auto repeated = TerminalNameCheck::repeatedNames(
+ {"L", "N", "O1", "N", "O2", "N", "L"});
+ QCOMPARE(repeated.size(), 2);
+ QCOMPARE(repeated.at(0), qMakePair(QString("L"), 2));
+ QCOMPARE(repeated.at(1), qMakePair(QString("N"), 3));
+ QCOMPARE(TerminalNameCheck::describe(repeated),
+ QString::fromUtf8("L ×2, N ×3"));
+ }
+
+ void surroundingSpacesAreIgnored()
+ {
+ QCOMPARE(TerminalNameCheck::repeatedNames({"PE", " PE "}).size(), 1);
+ }
+
+ void caseMatters()
+ {
+ QVERIFY(TerminalNameCheck::repeatedNames({"n", "N"}).isEmpty());
+ }
+
+ // Unnamed terminals are reported as unnamed, never as a repeat of "".
+ void unnamedAreNotRepeats()
+ {
+ const QStringList names{"", " ", "1", ""};
+ QVERIFY(TerminalNameCheck::repeatedNames(names).isEmpty());
+ QCOMPARE(TerminalNameCheck::unnamedCount(names), 3);
+ }
+
+ // describe() goes into a tr() message with %1: a name holding %2 stays literal.
+ void describeKeepsPercentLiteral()
+ {
+ QCOMPARE(TerminalNameCheck::describe({qMakePair(QString("%2"), 2)}),
+ QString::fromUtf8("%2 ×2"));
+ }
+};
+
+QTEST_GUILESS_MAIN(tst_terminalnamecheck)
+
+#include "tst_terminalnamecheck.moc"