Add a terminal name check when saving an element

Discussion #1157. IEC 61666 §4.1 requires each terminal to be
identified unambiguously within its object, so two terminals of one
element must not share a name.

On save, the element editor now:
- refuses to save when two terminals share a name, lists the names
  ("N ×3") and selects those terminals;
- warns, and still saves, when a terminal has no name. Folio reports,
  conductor definitions and thumbnails are skipped.

Names are compared after trimming surrounding spaces, case sensitive.

Settings > General > Editor has a new checkbox, on by default, that
turns both checks off (elementeditor/check-terminal-names).

--check-elements applies the same rule: repeated names are a FAIL,
missing names a WARN. It ignores the setting, since it is an explicit
check. On the shipped collection this reports 82 FAILs, the elements
that repeat a terminal name today.

The rule lives in the header-only editor/terminalnamecheck.h, shared
by both, and is unit tested by tst_terminalnamecheck.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-30 07:24:55 +13:00
parent f15736bff7
commit bd4d942d71
7 changed files with 242 additions and 1 deletions
+24 -1
View File
@@ -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;
}
+91
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef TERMINALNAMECHECK_H
#define TERMINALNAMECHECK_H
#include <QHash>
#include <QList>
#include <QPair>
#include <QString>
#include <QStringList>
/**
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<QPair<QString, int>> repeatedNames(const QStringList &names)
{
QHash<QString, int> 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<QPair<QString, int>> 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<QPair<QString, int>> &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
+48
View File
@@ -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<PartTerminal *> terminals;
QStringList names;
for (auto qgi : m_elmt_scene -> items()) {
if (auto terminal = qgraphicsitem_cast<PartTerminal *>(qgi)) {
terminals << terminal;
names << terminal -> terminalName();
}
}
const auto repeated = TerminalNameCheck::repeatedNames(names);
if (!repeated.isEmpty())
{
errors << qMakePair (tr("Noms de bornes en double"),
tr("<br><b>Erreur</b> :"
"<br>Plusieurs bornes portent le même nom : %1."
"<br><b>Solution</b> :"
"<br>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("<br>%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);
}
@@ -25,6 +25,7 @@
#include "../../utils/qetutils.h"
#include "../../qetmessagebox.h"
#include "../../textgrid.h"
#include "../../editor/terminalnamecheck.h"
#include "../nokde/kcolorbutton.h"
#include <QFileDialog>
#include <QFileInfo>
@@ -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());
@@ -1240,6 +1240,19 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="m_check_terminal_names_cb">
<property name="toolTip">
<string>À l'enregistrement d'un élément : refuser deux bornes portant le même nom, et signaler les bornes sans nom (IEC 61666).</string>
</property>
<property name="text">
<string>Vérifier les noms des bornes à l'enregistrement</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_6">
<property name="orientation">
@@ -1275,6 +1288,7 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
<tabstop>m_custom_tbt_path_cb</tabstop>
<tabstop>m_highlight_integrated_elements</tabstop>
<tabstop>m_default_elements_info</tabstop>
<tabstop>m_check_terminal_names_cb</tabstop>
<tabstop>m_lang_cb</tabstop>
<tabstop>m_dyn_text_font_pb</tabstop>
<tabstop>m_dyn_text_rotation_sb</tabstop>
+7
View File
@@ -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)
+55
View File
@@ -0,0 +1,55 @@
#include <QtTest>
#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"