Merge pull request #1438 from Kellermorph/wiring-manager

Add cable management: drawing, cores, numbering, cross-references and cable list
This commit is contained in:
Laurent Trinques
2026-10-10 16:33:12 +02:00
committed by GitHub
113 changed files with 18793 additions and 209 deletions
+26
View File
@@ -87,6 +87,15 @@ if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja")
--source ${CMAKE_CURRENT_SOURCE_DIR}/attached_plain_paste_probe.cpp
--fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet)
set_tests_properties(tst_attachedplainpaste PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE)
# Deleting a cable, undoing the delete twice and then editing anything
# has both cable commands tearing down the same cable. Runs under the
# same objects the application uses, so a second free is caught.
add_test(NAME tst_cableundointegration
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_cable_undo_probe.py
--build ${CMAKE_BINARY_DIR} --binary $<TARGET_FILE:qelectrotech>
--source ${CMAKE_CURRENT_SOURCE_DIR}/cable_undo_probe.cpp
--fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet)
set_tests_properties(tst_cableundointegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE)
endif()
if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja")
add_test(NAME tst_darkimageintegration
@@ -254,6 +263,23 @@ add_test(NAME tst_legacyelementtypes COMMAND tst_legacyelementtypes)
target_include_directories(tst_legacyelementtypes PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_legacyelementtypes PRIVATE Qt::Test)
# The cable type catalogue is plain data (it reads and writes the CSV file
# holding the cable kinds), so it is compiled here on its own, together with
# the material file helpers it shares and the element information wording.
add_executable(
tst_cabletypelist
tst_cabletypelist.cpp
${QET_DIR}/sources/cablelist/cabletypelist.cpp
${QET_DIR}/sources/cablelist/cabletypelist.h
${QET_DIR}/sources/materiallist/materiallist.cpp
${QET_DIR}/sources/materiallist/materiallist.h
${QET_DIR}/sources/qetinformation.cpp
${QET_DIR}/sources/qetinformation.h)
add_test(NAME tst_cabletypelist COMMAND tst_cabletypelist)
target_include_directories(tst_cabletypelist PRIVATE ${QET_DIR}/sources)
# qetinformation.h uses QRegularExpressionValidator, which lives in QtGui.
target_link_libraries(tst_cabletypelist PRIVATE Qt::Test Qt::Gui)
add_executable(
tst_qetpalette
tst_qetpalette.cpp
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Cable undo ownership regression, linked against the application's objects.
//
// Deleting a cable and undoing the delete twice leaves the cable out of
// the project while AddCableCommand still holds it, and the next edit
// makes the stack throw both commands away. Whoever of the two runs
// first frees the cable; the other one must not follow it into the freed
// memory. A raw pointer there meant freeing the same cable twice, which
// a normal build hides and AddressSanitizer reports as a heap
// use-after-free in ~AddCableCommand.
#include <QApplication>
#include <QFontDatabase>
#include <QSettings>
#include <QTemporaryDir>
#include <QUuid>
#include <cstdio>
#include <stdexcept>
#include "../../sources/diagram.h"
#include "../../sources/qetproject.h"
#include "../../sources/qetmessagebox.h"
#include "../../sources/cable/addcablecommand.h"
#include "../../sources/cable/cable.h"
#include "../../sources/cable/cablepart.h"
#include "../../sources/cable/editcablecommand.h"
static void check(bool value, const char *message)
{
if (!value) throw std::runtime_error(message);
}
// Any edit the user makes afterwards: pushing it is what makes the stack
// discard the commands it still holds undone, and that is where the two
// destructors meet the same cable.
struct AnyEdit : QUndoCommand
{
void redo() override {}
void undo() override {}
};
int main(int argc, char **argv)
{
QApplication app(argc, argv);
std::freopen(argv[2], "w", stdout);
QTemporaryDir settings;
QSettings::setDefaultFormat(QSettings::IniFormat);
QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings.path());
QCoreApplication::setOrganizationName("QETCableUndoRegression");
QETProject::setBackupEnabled(false);
QET::QetMessageBox::setNonInteractive(true);
QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf");
try
{
QETProject project(QString::fromLocal8Bit(argv[1]));
check(project.state() == QETProject::Ok, "fixture opens");
Diagram *scene = project.diagrams().first();
// The state the cable tool leaves behind when it has drawn one
// line and the dialog accepted it: a cable holding the section,
// a line on the folio pointing back at that cable.
CablePartData data;
data.uuid = QUuid::createUuid();
data.diagram = scene->uuid();
data.p1 = QPointF(100, 100);
data.p2 = QPointF(300, 100);
//The cable the create dialog hands over: owned by the project,
//which is the only way it may enter the project's list at all.
auto *cable = project.newCable();
cable->addPart(data);
auto *item = new CablePart(data);
item->setCable(cable);
scene->addItem(item);
// Draw the line.
scene->undoStack().push(new AddCableCommand(cable, item, scene, true));
check(project.cables().contains(cable), "drawn cable is in the project");
// Select all and delete.
scene->undoStack().push(new RemoveCableCommand(scene, {item}));
check(!project.cables().contains(cable), "delete took the cable out of the project");
// Ctrl+Z, Ctrl+Z: the cable is in nobody's hands now, but both
// commands still stand and both think they may have to clean it up.
scene->undoStack().undo();
scene->undoStack().undo();
check(!project.cables().contains(cable), "cable orphaned by two undos");
// The next edit of any kind: the stack throws the undone commands
// away, and ~RemoveCableCommand frees the cable before
// ~AddCableCommand gets to look at it.
scene->undoStack().push(new AnyEdit);
check(project.state() == QETProject::Ok, "project survives the edit");
std::puts("PASS: deleting a cable, undoing twice and editing again frees it exactly once");
}
catch (const std::exception &error)
{
std::printf("FAIL: %s\n", error.what());
return 1;
}
return 0;
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Link the cable undo integration probe using an existing Ninja app build.
Reuses app objects without duplicating the app or introducing a core-library
refactor. No application source or existing executable is replaced. The
generated manifest, probe binary, reports and export fixtures stay in build/.
"""
import argparse
import pathlib
import re
import shutil
import subprocess
import sys
parser = argparse.ArgumentParser()
parser.add_argument('--build', required=True)
parser.add_argument('--binary', required=True)
parser.add_argument('--source', required=True)
parser.add_argument('--fixture', required=True)
args = parser.parse_args()
build = pathlib.Path(args.build).resolve()
binary = pathlib.Path(args.binary).resolve()
source = pathlib.Path(args.source).resolve()
fixture = pathlib.Path(args.fixture).resolve()
manifest = (build / 'build.ninja').read_text(encoding='utf-8')
match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE)
if not match:
sys.exit('Cannot find the application main object in the Ninja build.')
main_object = match.group(1)
probe_object = main_object.replace('main.cpp.', 'cable_undo_probe.cpp.')
probe_binary = binary.with_name('cable_undo_probe' + binary.suffix)
def escape(path):
return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ')
main_source = source.parents[2] / 'sources' / 'main.cpp'
manifest = manifest.replace(main_object, probe_object)
manifest = manifest.replace(escape(main_source), escape(source))
if binary.suffix:
manifest = manifest.replace(binary.name, probe_binary.name)
else:
old_target = escape(binary.name if binary.parent == build else binary)
new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary)
manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':')
old_file = binary.name if binary.parent == build else binary.as_posix()
new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix()
manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$',
' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE)
probe_manifest = build / 'cable-undo-probe.ninja'
probe_manifest.write_text(manifest, encoding='utf-8')
ninja = shutil.which('ninja')
if not ninja:
sys.exit('Ninja is required for the integration probe.')
target = probe_binary.name if probe_binary.parent == build else str(probe_binary)
subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True)
output = build / 'cable-undo'
output.mkdir(exist_ok=True)
report = output / 'results.txt'
result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90)
if report.exists():
print(report.read_text(encoding='utf-8', errors='replace'))
sys.exit(result.returncode)
+205
View File
@@ -0,0 +1,205 @@
/*
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/>.
*/
#include "cablelist/cabletypelist.h"
#include <QFile>
#include <QTemporaryDir>
#include <QTest>
/**
The cable type catalogue is read back the way it was written, and the
three columns it really uses are the only ones a new file gets:
- a catalogue carrying the material file's whole shape (ten columns,
two header lines) reads as ten columns, its machine header line is
not mistaken for a cable, and the type comes out of it : this is
the catalogue a user already has on disk;
- a file the program creates itself holds the two header lines and
the three columns, no more;
- a file written by hand with the label line alone still gives the
type, so dropping the machine header never costs a column;
- and a line of cables is never taken for the machine header, even
when it happens to be written in key shaped words.
*/
class tst_cabletypelist : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
QString write(const QString &name, const QString &content)
{
const QString path = m_dir.filePath(name);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return {};
}
file.write(content.toUtf8());
file.close();
return path;
}
bool load(const QString &path, CableTypeListData *data)
{
QString error;
const bool ok = CableTypeList::load(path, data, &error);
if (!ok) {
qWarning("%s", qPrintable(error));
}
return ok;
}
private slots:
void tenColumnCatalogueReadsBack()
{
const QString path = write(QStringLiteral("ten.csv"),
QStringLiteral(
"Désignation;Nombre d'âmes;Couleurs des âmes;Section du câble;Couleur du fourreau;Fabricant;Numéro de commande;Fournisseur;Description textuelle;Notes\n"
"designation;cores;core_colors;section;color;manufacturer;manufacturer_reference;supplier;description;notes\n"
"H07V-K 3G1,5;3;wh|bn|bl;1.5;PVC;Nexans;X123;Rexel;courant fort;note\n"
"NYM-J 3G2,5;3;wh|bn|bl;2.5;PVC;;;;;\n"));
QVERIFY(!path.isEmpty());
CableTypeListData data;
QVERIFY2(load(path, &data), "the catalogue could not be read");
//The machine header line is not one of the cables...
QCOMPARE(data.records.size(), 2);
//... and every column it names is read under that name, even the
//ones this program never gave a meaning to.
QCOMPARE(data.columns, QStringList({
QStringLiteral("designation"), QStringLiteral("cores"),
QStringLiteral("core_colors"), QStringLiteral("section"),
QStringLiteral("color"), QStringLiteral("manufacturer"),
QStringLiteral("manufacturer_reference"),
QStringLiteral("supplier"), QStringLiteral("description"),
QStringLiteral("notes")}));
QCOMPARE(CableTypeList::designation(data.records.at(0)),
QStringLiteral("H07V-K 3G1,5"));
QCOMPARE(CableTypeList::coreCount(data.records.at(0)), 3);
QCOMPARE(CableTypeList::coreColors(data.records.at(0)),
QStringList({QStringLiteral("wh"), QStringLiteral("bn"),
QStringLiteral("bl")}));
//A column outside the three the program uses keeps its name and
//its data.
QCOMPARE(data.records.at(0).value(QStringLiteral("section")),
QStringLiteral("1.5"));
QCOMPARE(CableTypeList::designation(data.records.at(1)),
QStringLiteral("NYM-J 3G2,5"));
}
void newFileHasThreeColumns()
{
const QString path = m_dir.filePath(QStringLiteral("new.csv"));
QVERIFY2(CableTypeList::createFile(path), "the file was not created");
CableTypeListData data;
QVERIFY(load(path, &data));
QCOMPARE(data.columns, QStringList({
QStringLiteral("designation"), QStringLiteral("cores"),
QStringLiteral("core_colors")}));
QVERIFY(data.records.isEmpty());
//The label line reads the way the cable form names the type.
QCOMPARE(CableTypeList::translatedHeader(data.columns), QStringList({
QStringLiteral("Designation"),
QStringLiteral("Number of cores"),
QStringLiteral("Core colors")}));
CableTypeRecord record;
record.setValue(QStringLiteral("designation"),
QStringLiteral("H07V-K 3G1,5"));
record.setValue(QStringLiteral("cores"), QStringLiteral("3"));
record.setValue(QStringLiteral("core_colors"),
QStringLiteral("wh|bn|bl"));
QVERIFY2(CableTypeList::appendRecord(path, record),
"the record was not appended");
CableTypeListData read_back;
QVERIFY(load(path, &read_back));
QCOMPARE(read_back.columns, data.columns);
QCOMPARE(read_back.records.size(), 1);
QCOMPARE(CableTypeList::designation(read_back.records.first()),
QStringLiteral("H07V-K 3G1,5"));
QCOMPARE(CableTypeList::coreCount(read_back.records.first()), 3);
QCOMPARE(CableTypeList::coreColors(read_back.records.first()),
QStringList({QStringLiteral("wh"), QStringLiteral("bn"),
QStringLiteral("bl")}));
}
//The folder a cable type file goes into may not exist yet: a fresh
//profile has no documents folder, and a path typed into the settings
//may name one which was never created. The write makes the folder
//rather than failing with a message which says nothing about why.
void aMissingFolderIsCreated()
{
const QString path = m_dir.filePath(
QStringLiteral("not/there/yet/qet_cable_types.csv"));
QVERIFY(!QFile::exists(path));
QString error;
QVERIFY2(CableTypeList::createFile(path, &error), qPrintable(error));
QVERIFY2(QFile::exists(path), "the file was not created");
CableTypeListData data;
QVERIFY2(load(path, &data), "the file just created could not be read");
QVERIFY(data.records.isEmpty());
}
void labelLineAloneStillGivesTheType()
{
const QString path = write(QStringLiteral("hand.csv"),
QStringLiteral(
"Désignation;Nombre d'âmes;Couleurs des âmes\n"
"H07V-K 3G1,5;3;wh|bn|bl\n"));
QVERIFY(!path.isEmpty());
CableTypeListData data;
QVERIFY2(load(path, &data), "the hand written file could not be read");
QCOMPARE(data.columns, QStringList({
QStringLiteral("designation"), QStringLiteral("cores"),
QStringLiteral("core_colors")}));
QCOMPARE(data.records.size(), 1);
QCOMPARE(CableTypeList::designation(data.records.first()),
QStringLiteral("H07V-K 3G1,5"));
QCOMPARE(CableTypeList::coreCount(data.records.first()), 3);
}
void aCableIsNeverMistakenForTheHeader()
{
//Three cells which all look like column keys, none of which means
//anything here : far more likely to be a cable than a header.
const QString path = write(QStringLiteral("unknown.csv"),
QStringLiteral(
"Désignation;Nombre d'âmes;Couleurs des âmes\n"
"ymj;;wh\n"
"H07V-K 3G1,5;3;wh|bn|bl\n"));
QVERIFY(!path.isEmpty());
CableTypeListData data;
QVERIFY2(load(path, &data), "the file could not be read");
QCOMPARE(data.records.size(), 2);
QCOMPARE(CableTypeList::designation(data.records.first()),
QStringLiteral("ymj"));
QCOMPARE(CableTypeList::designation(data.records.last()),
QStringLiteral("H07V-K 3G1,5"));
}
};
QTEST_APPLESS_MAIN(tst_cabletypelist)
#include "tst_cabletypelist.moc"