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 8489f8def..d6346d3f7 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" @@ -839,6 +840,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 29afaa992..9476eb944 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) + # configprofile.h is header-only: which settings a saved configuration # profile carries, and how loading one replaces the live settings. add_executable(tst_configprofile tst_configprofile.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"