diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index e3aad8da9..3d44ef907 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -177,6 +177,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/wiringlistdialog.h
${QET_DIR}/sources/ui/wiringlistdialog.cpp
${QET_DIR}/sources/conductornumexport.h
+ ${QET_DIR}/sources/devicebomexport.cpp
+ ${QET_DIR}/sources/devicebomexport.h
${QET_DIR}/sources/conductorprofile.cpp
${QET_DIR}/sources/conductorprofile.h
${QET_DIR}/sources/conductorproperties.cpp
diff --git a/docs/smart-device-bom.md b/docs/smart-device-bom.md
new file mode 100644
index 000000000..583f2ce74
--- /dev/null
+++ b/docs/smart-device-bom.md
@@ -0,0 +1,136 @@
+# Smart Device Information and BOM CSV
+
+## Architecture
+
+- A `.qet` project is XML, rooted at `project`. `QETProject::toXml()` and
+ `write()` serialize it; the file constructor, `readProjectXml()` and
+ `readDiagramsXml()` load it (`sources/qetproject.cpp`). Diagrams are
+ serialized by `Diagram::toXml()/fromXml()` (`sources/diagram.cpp`).
+- `.elmt` symbol definitions are stored in collections and embedded into a
+ project by `XmlElementCollection`. `Element::buildFromXml()` builds their
+ graphical representation (`sources/qetgraphicsitem/element.cpp`).
+- A placed component is an `Element`. `ElementData` holds its type and
+ `m_informations`, a `DiagramContext` key/value map. Instance properties are
+ read/written by `Element::fromXml()/toXml()` as `elementInformations` with
+ `elementInformation name="..."` children.
+- `ElementPropertiesWidget` hosts `ElementInfoWidget`; the latter creates
+ rows from `QETInformation::elementInfoKeys()`. Edits use
+ `ChangeElementInformationCommand`, including live editing and undo/redo.
+- Existing reporting uses the derived SQLite `projectDataBase`,
+ `element_nomenclature_view`, `ElementQueryWidget`, `BOMExportDialog` and
+ `QetGraphicsTableFactory`. `cli_export.cpp` also provides `--export-bom`.
+ The database is not the authoritative project storage.
+
+## Device Properties
+
+`sources/properties/deviceinformation.h/.cpp` provide a small value type
+and mapping to the existing context. There is no parallel device store.
+`toContext(existing_context)` preserves unrelated properties and visibility
+flags. Callers changing a placed element should use
+`ChangeElementInformationCommand` to retain undo/redo and dirty tracking.
+
+| Device field | XML/context key or source | Origin | CSV BOM |
+| --- | --- | --- | --- |
+| Device tag | `label` | Existing; BOM resolves formula labels | Yes |
+| Manufacturer | `manufacturer` | Existing | Yes |
+| Part number | `manufacturer_reference` | Existing | Yes |
+| Model | `model` | New | Yes |
+| Description | `description` | Existing | Yes |
+| Category | `category` | New | Yes |
+| Quantity | `quantity` | Existing | Yes |
+| Page | Resolved diagram folio | Derived | Yes |
+| Voltage rating | `voltage_rating` | New | No; stored metadata |
+| Current rating | `current_rating` | New | No; stored metadata |
+| Notes | `notes` | New | Yes |
+
+No XML version bump or migration is needed. Absent properties remain empty;
+empty properties are omitted by the existing XML writer. Older keys keep
+their original meaning. Generic context loading preserves unknown keys.
+As before, serialization trims leading/trailing whitespace. Compatibility
+with every historical QET release is not implied.
+
+Quantity remains text, as in existing QET properties, so decimal quantities
+and existing unit conventions are not silently changed. A missing quantity
+stays empty in the BOM: it is not silently assumed to be one. Ratings are
+text (for example `24 V DC` and `16 A`). No numeric aggregation is performed.
+
+The five new rows are automatically available in the existing information
+tab for normal/master symbols and in the symbol definition editor. No new
+panel is required. Notes use the existing single-line property editor;
+programmatically loaded multiline values are supported by XML and CSV.
+New keys are also exposed in the existing SQL nomenclature view.
+
+## BOM Policy
+
+`sources/devicebomexport.h/.cpp` collect current in-memory project data,
+including unsaved property changes, without a SQL cache dependency.
+
+- One row per Simple or Master element.
+- Slave symbols, terminals, page reports, thumbnails and conductor definitions
+ are excluded; a master is counted once, not once per contact representation.
+ Even unlinked slave symbols are excluded, matching this device-level policy.
+- The existing `exclude_from_bom` property is respected.
+- Empty tags and part numbers do not discard otherwise eligible elements.
+- Rows follow project page order, then device tag, then UUID for stable ties.
+ `Page` is the resolved folio label shown by QET; an empty label falls back
+ to the one-based project page position.
+- Identical part numbers remain separate rows. Grouping is deferred because
+ conflicting manufacturers, ratings, tags and quantities need explicit rules.
+- Auxiliary accessories are not expanded into extra rows in this MVP.
+- A PLC master can be listed as one physical Master device, but its embedded
+ PLC I/O entries are never expanded into BOM rows.
+
+The Project menu action **Export BOM as CSV** exports fixed columns:
+`No, Device tag, Manufacturer, Part number, Model, Description, Category,
+Quantity, Page, Notes`. The legacy configurable export and CLI export retain
+their previous behavior. Voltage and current ratings deliberately remain
+device metadata in this MVP because they were not part of the requested BOM
+schema; they can be added later without changing the stored project data.
+
+CSV is UTF-8 with a BOM, comma-delimited, CRLF record endings, double-quoted
+fields and doubled embedded quotes. Embedded newlines are retained. Values
+are exported verbatim so device tags and part numbers are not altered. CSV
+quoting does not disable spreadsheet formulas: only export trusted projects,
+or import all columns as **text** when inspecting untrusted project data. Text
+import also preserves leading zeros.
+`QSaveFile` commits atomically; open, write and commit errors are reported.
+Cancel does not write a file. Read-only projects can still be exported.
+
+## Verification
+
+On the configured Windows/MSYS2 Qt5 system, from the repository root:
+
+```powershell
+.\tests\qttest\run-smart-device-tests.ps1
+```
+
+The runner builds the application with `debug` and `no_kf5`, then links a QtTest
+executable against the real application objects. Use `-SkipApplicationBuild`
+only after building the current application sources. Results and generated
+sample `.qet`, CSV and properties screenshot are under
+`build-debug/smart-device-tests/`. CMake's explicit source list includes the
+new files; qmake discovers them through its existing globs.
+
+Tests cover absent/legacy properties, context/XML round trips, custom-key
+preservation, project save/reopen, BOM filtering and ordering, duplicate part
+numbers, CSV encoding/escaping, write success/failure, properties UI undo/redo,
+and the existing database report view.
+
+Manual checks:
+
+1. Open an existing project and edit a normal or master element's information
+ tab. Existing fields and the new Model/Category/ratings/Notes rows must show.
+2. Enter manufacturer, part number, quantity and new properties. Apply, undo,
+ redo, save, close and reopen. Verify the same values are present.
+3. Give two elements the same part number on different pages. Export through
+ Project > Export BOM as CSV. Expect separate rows with their own tags/pages.
+4. Enable the existing exclusion checkbox for one element and export again.
+ Its row must be absent; changing a property without saving must affect CSV.
+5. Open CSV with a UTF-8 CSV reader (import text columns when using Excel).
+ Check Turkish characters, leading-zero part numbers, commas and quotes.
+6. Cancel export and try a non-writable destination. Cancellation must not
+ create a file; failure must show an error without destroying an existing CSV.
+
+Suggested review pieces: (1) device mapping and property/report registration,
+(2) collector and CSV menu action, (3) tests and documentation. No cable,
+PLC I/O, catalog integration or cabinet-layout feature is added.
diff --git a/lang/qet_en.ts b/lang/qet_en.ts
index bcf971599..c8776c3f0 100644
--- a/lang/qet_en.ts
+++ b/lang/qet_en.ts
@@ -7568,6 +7568,34 @@ Available options:
Exporter au format CSV
Export to CSV
+
+
+
+ Exporter la nomenclature des appareils au format CSV
+ Export device BOM as CSV
+
+
+
+ Fichiers CSV (*.csv)
+ CSV files (*.csv)
+
+
+
+ Exporter la nomenclature
+ Export BOM
+
+
+
+ Impossible d'enregistrer la nomenclature dans %1.
+%2
+ Could not save the BOM to %1.
+%2
+
+
+
+ Nomenclature exportée dans %1
+ BOM exported to %1
+
Gestionnaire de borniers (DEV)
@@ -10967,6 +10995,31 @@ Do you want to replace it ?
Description textuelle
Textual description
+
+
+ Modèle
+ Model
+
+
+
+ Catégorie
+ Category
+
+
+
+ Tension nominale
+ Voltage rating
+
+
+
+ Courant nominal
+ Current rating
+
+
+
+ Notes
+ Notes
+
Numéro d'article
diff --git a/lang/qet_tr.ts b/lang/qet_tr.ts
index ec58ef57e..69f34dc90 100644
--- a/lang/qet_tr.ts
+++ b/lang/qet_tr.ts
@@ -7685,6 +7685,34 @@ Mevcut seçenekler:
Exporter au format CSV
CSV formatında export et
+
+
+
+ Exporter la nomenclature des appareils au format CSV
+ Cihaz malzeme listesini CSV olarak dışa aktar
+
+
+
+ Fichiers CSV (*.csv)
+ CSV dosyaları (*.csv)
+
+
+
+ Exporter la nomenclature
+ Malzeme listesini dışa aktar
+
+
+
+ Impossible d'enregistrer la nomenclature dans %1.
+%2
+ Malzeme listesi %1 konumuna kaydedilemedi.
+%2
+
+
+
+ Nomenclature exportée dans %1
+ Malzeme listesi %1 konumuna aktarıldı
+
Gestionnaire de borniers (DEV)
@@ -10967,6 +10995,31 @@ Değiştirmek ister misiniz?
Description textuelle
Metin açıklaması
+
+
+ Modèle
+ Model
+
+
+
+ Catégorie
+ Kategori
+
+
+
+ Tension nominale
+ Anma gerilimi
+
+
+
+ Courant nominal
+ Anma akımı
+
+
+
+ Notes
+ Notlar
+
Numéro d'article
diff --git a/sources/devicebomexport.cpp b/sources/devicebomexport.cpp
new file mode 100644
index 000000000..485d341c5
--- /dev/null
+++ b/sources/devicebomexport.cpp
@@ -0,0 +1,119 @@
+/*
+ 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 "devicebomexport.h"
+
+#include "diagram.h"
+#include "qetgraphicsitem/element.h"
+#include "qetproject.h"
+
+#include
+#include
+#include
+
+namespace {
+QByteArray csvRecord(const QStringList &values)
+{
+ QStringList escaped;
+ for (QString value : values) {
+ value.replace(QLatin1Char('"'), QStringLiteral("\"\""));
+ escaped.append(QLatin1Char('"') + value + QLatin1Char('"'));
+ }
+ return (escaped.join(QLatin1Char(',')) + QStringLiteral("\r\n")).toUtf8();
+}
+}
+
+QList DeviceBomExport::collect(const QETProject &project)
+{
+ QList rows;
+ int page = 0;
+ for (Diagram *diagram : project.diagrams())
+ {
+ const QString page_position = QString::number(++page);
+ const QString page_label = diagram->border_and_titleblock.finalfolio().isEmpty()
+ ? page_position
+ : diagram->border_and_titleblock.finalfolio();
+ auto elements = diagram->elements();
+ // Scene stacking order is not stable across save/load. UUID breaks tag ties.
+ std::sort(elements.begin(), elements.end(), [](Element *a, Element *b) {
+ const int compare = QString::compare(a->actualLabel(), b->actualLabel());
+ return compare ? compare < 0 : a->uuid().toString() < b->uuid().toString();
+ });
+ for (Element *element : elements)
+ {
+ const auto type = element->elementData().m_type;
+ // Slaves are representations of a master, not additional devices.
+ // Terminal-specific inventory remains outside this MVP.
+ if (type != ElementData::Simple && type != ElementData::Master) {
+ continue;
+ }
+ const auto context = element->elementInformations();
+ if (context.value(QStringLiteral("exclude_from_bom")).toBool()) {
+ continue;
+ }
+ auto device = DeviceInformation::fromContext(context);
+ device.m_device_tag = element->actualLabel();
+ rows.append({device, page_label});
+ }
+ }
+ return rows;
+}
+
+QByteArray DeviceBomExport::toCsv(const QList &rows)
+{
+ // UTF-8 BOM lets desktop spreadsheet applications detect Turkish text too.
+ QByteArray csv("\xEF\xBB\xBF");
+ csv += csvRecord({QStringLiteral("No"), QStringLiteral("Device tag"),
+ QStringLiteral("Manufacturer"), QStringLiteral("Part number"),
+ QStringLiteral("Model"), QStringLiteral("Description"),
+ QStringLiteral("Category"), QStringLiteral("Quantity"),
+ QStringLiteral("Page"), QStringLiteral("Notes")});
+ int number = 0;
+ for (const auto &row : rows)
+ {
+ const auto &device = row.m_device;
+ csv += csvRecord({QString::number(++number), device.m_device_tag,
+ device.m_manufacturer, device.m_part_number, device.m_model,
+ device.m_description, device.m_category, device.m_quantity,
+ row.m_page, device.m_notes});
+ }
+ return csv;
+}
+
+bool DeviceBomExport::writeCsv(const QETProject &project, const QString &file_path,
+ QString *error)
+{
+ if (error) {
+ error->clear();
+ }
+ QSaveFile file(file_path);
+ if (!file.open(QIODevice::WriteOnly)) {
+ if (error) *error = file.errorString();
+ return false;
+ }
+ const auto csv = toCsv(collect(project));
+ if (file.write(csv) != csv.size()) {
+ if (error) *error = file.errorString();
+ file.cancelWriting();
+ return false;
+ }
+ if (!file.commit()) {
+ if (error) *error = file.errorString();
+ return false;
+ }
+ return true;
+}
diff --git a/sources/devicebomexport.h b/sources/devicebomexport.h
new file mode 100644
index 000000000..1fe0332d0
--- /dev/null
+++ b/sources/devicebomexport.h
@@ -0,0 +1,43 @@
+/*
+ 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 DEVICEBOMEXPORT_H
+#define DEVICEBOMEXPORT_H
+
+#include "properties/deviceinformation.h"
+
+#include
+#include
+#include
+
+class QETProject;
+
+class DeviceBomExport
+{
+ public:
+ struct Row {
+ DeviceInformation m_device;
+ QString m_page;
+ };
+
+ static QList collect(const QETProject &project);
+ static QByteArray toCsv(const QList &rows);
+ static bool writeCsv(const QETProject &project, const QString &file_path,
+ QString *error = nullptr);
+};
+
+#endif // DEVICEBOMEXPORT_H
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index 724fde835..d04f102d3 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -21,6 +21,7 @@
#include "QWidgetAnimation/qwidgetanimation.h"
#include "autoNum/ui/autonumberingdockwidget.h"
#include "conductornumexport.h"
+#include "devicebomexport.h"
#include "diagramcommands.h"
#include "diagramevent/diagrameventaddimage.h"
#ifdef QET_HAS_QTPDF
@@ -470,6 +471,34 @@ void QETDiagramEditor::setUpActions()
bom.exec();
});
+ m_device_bom_export = new QAction(QET::Icons::DocumentSpreadsheet,
+ tr("Exporter la nomenclature des appareils au format CSV"), this);
+ m_device_bom_export->setObjectName(QStringLiteral("export_device_bom_csv"));
+ connect(m_device_bom_export, &QAction::triggered, this, [this]() {
+ auto *view = currentProjectView();
+ if (!view) return;
+ QPointer project = view->project();
+ QString dir = project->currentDir();
+ if (dir.isEmpty()) dir = QETApp::documentDir();
+ QFileDialog dialog(
+ this,
+ tr("Exporter la nomenclature des appareils au format CSV"),
+ QDir(dir).filePath(QStringLiteral("bom.csv")),
+ tr("Fichiers CSV (*.csv)"));
+ dialog.setAcceptMode(QFileDialog::AcceptSave);
+ dialog.setDefaultSuffix(QStringLiteral("csv"));
+ if (dialog.exec() != QDialog::Accepted || !project) return;
+ const QString path = dialog.selectedFiles().value(0);
+ if (path.isEmpty()) return;
+ QString error;
+ if (!DeviceBomExport::writeCsv(*project, path, &error)) {
+ QMessageBox::critical(this, tr("Exporter la nomenclature"),
+ tr("Impossible d'enregistrer la nomenclature dans %1.\n%2").arg(path, error));
+ } else {
+ statusBar()->showMessage(tr("Nomenclature exportée dans %1").arg(path), 5000);
+ }
+ });
+
//Add a nomenclature item
m_add_nomenclature = new QAction(QET::Icons::TableOfContent, tr("Ajouter une nomenclature"), this);
connect(m_add_nomenclature, &QAction::triggered, this, [=]() {
@@ -948,6 +977,7 @@ void QETDiagramEditor::setUpMenu()
menu_project -> addAction(m_add_summary);
menu_project -> addAction(m_add_nomenclature);
menu_project -> addAction(m_csv_export);
+ menu_project -> addAction(m_device_bom_export);
menu_project -> addAction(m_project_export_conductor_num);
menu_project -> addAction(m_terminal_strip_dialog);
menu_project -> addAction(m_project_terminalBloc);
@@ -1799,6 +1829,7 @@ void QETDiagramEditor::slot_updateActions()
m_add_summary -> setEnabled(editable_project);
m_add_nomenclature -> setEnabled(editable_project);
m_csv_export -> setEnabled(editable_project);
+ m_device_bom_export -> setEnabled(opened_project);
m_project_export_conductor_num-> setEnabled(opened_project);
m_terminal_strip_dialog -> setEnabled(editable_project);
m_project_export_wiring_list -> setEnabled(opened_project);
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index ed07a2395..313d5e803 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -203,6 +203,7 @@ class QETDiagramEditor : public QETMainWindow
*m_remove_diagram_from_project, ///< Delete a diagram from the current project
*m_clean_project, ///< Clean the content of the current project by removing useless items
*m_csv_export, ///< generate nomenclature
+ *m_device_bom_export, ///< Export an ungrouped device BOM
*m_add_nomenclature, ///< Add nomenclature graphics item;
*m_add_summary, ///.
+*/
+#include
+#include
+#include
+#include
+#include
+
+#include "ElementsCollection/xmlelementcollection.h"
+#include "dataBase/projectdatabase.h"
+#include "devicebomexport.h"
+#include "diagram.h"
+#include "qetgraphicsitem/element.h"
+#include "qetinformation.h"
+#include "qetproject.h"
+#include "qetresult.h"
+#include "ui/elementinfopartwidget.h"
+#include "ui/elementinfowidget.h"
+
+class SmartDeviceTest : public QObject
+{
+ Q_OBJECT
+ QTemporaryDir m_settings;
+
+ Element *addElement(QETProject &project, Diagram *diagram, const QString &tag,
+ ElementData::Type type = ElementData::Simple)
+ {
+ QDomDocument doc;
+ doc.setContent(QStringLiteral(
+ ""
+ "Test device"
+ ""));
+ const QString name = QUuid::createUuid().toString(QUuid::WithoutBraces) + ".elmt";
+ if (!project.embeddedElementCollection()->addElementDefinition("import", name, doc.documentElement()))
+ return nullptr;
+ int state = -1;
+ auto *element = new Element(ElementsLocation("embed://import/" + name, &project), nullptr, &state);
+ if (state != 0) {
+ delete element;
+ return nullptr;
+ }
+ diagram->addItem(element);
+ auto data = element->elementData();
+ data.m_type = type;
+ data.m_informations.addValue("label", tag);
+ element->setElementData(data);
+ return element;
+ }
+
+ DeviceInformation sample() const
+ {
+ DeviceInformation info;
+ info.m_device_tag = "-K1";
+ info.m_manufacturer = QString::fromUtf8("\xC3\x96rnek Elektrik");
+ info.m_part_number = "001234";
+ info.m_model = "RX-24";
+ info.m_description = QString::fromUtf8("G\xC3\xBC\xC3\xA7 kontakt\xC3\xB6r\xC3\xBC");
+ info.m_category = "Contactor";
+ info.m_quantity = "2.5";
+ info.m_voltage_rating = "24 V DC";
+ info.m_current_rating = "16 A";
+ info.m_notes = "Panel A, \"spare\"; check\nSecond line";
+ return info;
+ }
+
+private slots:
+ void initTestCase()
+ {
+ QCoreApplication::setOrganizationName("QET-SmartDeviceTests");
+ QCoreApplication::setApplicationName("IsolatedTests");
+ QSettings::setDefaultFormat(QSettings::IniFormat);
+ QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, m_settings.path());
+ QSettings::setPath(QSettings::IniFormat, QSettings::SystemScope, m_settings.path());
+ QETProject::setBackupEnabled(false);
+ }
+
+ void emptyAndLegacyContext()
+ {
+ const auto empty = DeviceInformation::fromContext(DiagramContext());
+ QVERIFY(empty.toContext().keys().isEmpty());
+ QVERIFY(empty.m_quantity.isEmpty());
+ QDomDocument doc;
+ QVERIFY(doc.setContent(QStringLiteral(
+ "K7"
+ "0007"
+ "")));
+ DiagramContext context;
+ context.fromXml(doc.documentElement(), "elementInformation");
+ const auto legacy = DeviceInformation::fromContext(context);
+ QCOMPARE(legacy.m_device_tag, QString("K7"));
+ QCOMPARE(legacy.m_part_number, QString("0007"));
+ QVERIFY(legacy.m_model.isEmpty());
+ QVERIFY(legacy.m_voltage_rating.isEmpty());
+ QVERIFY(legacy.m_current_rating.isEmpty());
+ QVERIFY(legacy.m_notes.isEmpty());
+ }
+
+ void contextRoundTripAndMerge()
+ {
+ DiagramContext original;
+ original.addValue("custom_value", "Keep", false);
+ original.addValue("manufacturer", "Old", false);
+ original.addValue("exclude_from_bom", "true");
+ auto info = sample();
+ auto context = info.toContext(original);
+ QVERIFY(!context.keyMustShow("manufacturer"));
+ QVERIFY(!context.keyMustShow("custom_value"));
+ QCOMPARE(context.value("custom_value").toString(), QString("Keep"));
+ QDomDocument doc;
+ auto xml = doc.createElement("elementInformations");
+ doc.appendChild(xml);
+ context.toXml(xml, "elementInformation");
+ QDomDocument reparsed;
+ QVERIFY(reparsed.setContent(doc.toByteArray()));
+ DiagramContext restored;
+ restored.fromXml(reparsed.documentElement(), "elementInformation");
+ QVERIFY(restored == context);
+ QVERIFY(DeviceInformation::fromContext(restored).toContext() == info.toContext());
+ info.m_model.clear();
+ const auto cleared = info.toContext(restored);
+ QVERIFY(!cleared.contains("model"));
+ QCOMPARE(cleared.value("exclude_from_bom").toString(), QString("true"));
+ }
+
+ void projectSaveLoad()
+ {
+ QTemporaryDir temp;
+ QETProject project;
+ auto *diagram = project.addNewDiagram();
+ diagram->border_and_titleblock.setFolio("A-01");
+ auto *element = addElement(project, diagram, "-K1");
+ QVERIFY(element);
+ element->setElementInformations(sample().toContext());
+ auto *second_diagram = project.addNewDiagram();
+ second_diagram->border_and_titleblock.setFolio("A-02");
+ diagram->border_and_titleblock.setFolioData(1, 2);
+ second_diagram->border_and_titleblock.setFolioData(2, 2);
+ auto *legacy = addElement(project, second_diagram, "-K2");
+ QVERIFY(legacy);
+ const auto before = DeviceBomExport::toCsv(DeviceBomExport::collect(project));
+ project.setFilePath(temp.filePath("roundtrip.qet"));
+ const auto result = project.write();
+ QVERIFY2(result.isOk(), qPrintable(result.errorMessage()));
+ QETProject restored(project.filePath());
+ QCOMPARE(restored.state(), QETProject::Ok);
+ QCOMPARE(restored.diagrams().size(), 2);
+ const auto rows = DeviceBomExport::collect(restored);
+ QCOMPARE(rows.size(), 2);
+ QCOMPARE(rows.first().m_page, QString("A-01"));
+ QCOMPARE(rows.last().m_page, QString("A-02"));
+ QVERIFY(rows.first().m_device.toContext() == sample().toContext());
+ QVERIFY(rows.last().m_device.m_model.isEmpty());
+ QVERIFY(rows.last().m_device.m_quantity.isEmpty());
+ QCOMPARE(DeviceBomExport::toCsv(rows), before);
+
+ const QString artifacts = qEnvironmentVariable("QET_SMART_DEVICE_ARTIFACT_DIR");
+ if (!artifacts.isEmpty()) {
+ QDir().mkpath(artifacts);
+ restored.setFilePath(QDir(artifacts).filePath("sample.qet"));
+ QVERIFY(restored.write().isOk());
+ QVERIFY(DeviceBomExport::writeCsv(restored, QDir(artifacts).filePath("sample.csv")));
+ }
+ }
+
+ void collectDevices()
+ {
+ QETProject project;
+ auto *first = project.addNewDiagram();
+ auto *second = project.addNewDiagram();
+ first->border_and_titleblock.setFolio("DEV-01");
+ second->border_and_titleblock.setFolio("DEV-10");
+ first->border_and_titleblock.setFolioData(1, 2);
+ second->border_and_titleblock.setFolioData(2, 2);
+ for (auto type : {ElementData::Simple, ElementData::Master, ElementData::Terminal,
+ ElementData::Slave, ElementData::NextReport, ElementData::PreviousReport,
+ ElementData::Thumbnail, ElementData::ConductorDefinition}) {
+ auto *element = addElement(project, first, QString::number(type), type);
+ QVERIFY(element);
+ if (type == ElementData::Master) {
+ auto data = element->elementData();
+ data.m_master_type = ElementData::PLC;
+ data.m_plc_master_data.ios.resize(2);
+ element->setElementData(data);
+ }
+ }
+ auto *excluded = addElement(project, first, "Excluded");
+ QVERIFY(excluded);
+ auto context = excluded->elementInformations();
+ context.addValue("exclude_from_bom", "true");
+ excluded->setElementInformations(context);
+ for (const QString tag : {"Z2", "A2"}) {
+ auto *element = addElement(project, second, tag);
+ QVERIFY(element);
+ auto info = sample();
+ info.m_device_tag = tag;
+ element->setElementInformations(info.toContext());
+ }
+ const auto rows = DeviceBomExport::collect(project);
+ QCOMPARE(rows.size(), 4);
+ QCOMPARE(rows.at(0).m_page, QString("DEV-01"));
+ QCOMPARE(rows.at(0).m_device.m_device_tag, QString("1"));
+ QCOMPARE(rows.at(1).m_device.m_device_tag, QString("8"));
+ QCOMPARE(rows.at(2).m_page, QString("DEV-10"));
+ QCOMPARE(rows.at(2).m_device.m_device_tag, QString("A2"));
+ QCOMPARE(rows.at(3).m_device.m_device_tag, QString("Z2"));
+ QCOMPARE(rows.at(2).m_device.m_part_number, rows.at(3).m_device.m_part_number);
+ QCOMPARE(rows.at(2).m_device.m_quantity, QString("2.5"));
+ QCOMPARE(DeviceBomExport::toCsv(rows), DeviceBomExport::toCsv(DeviceBomExport::collect(project)));
+ }
+
+ void emptyFolioFallsBackToPagePosition()
+ {
+ QETProject project;
+ auto *diagram = project.addNewDiagram();
+ diagram->border_and_titleblock.setFolio(QString());
+ diagram->border_and_titleblock.setFolioData(1, 1);
+ QVERIFY(addElement(project, diagram, "K1"));
+ const auto rows = DeviceBomExport::collect(project);
+ QCOMPARE(rows.size(), 1);
+ QCOMPARE(rows.first().m_page, QString("1"));
+ }
+
+ void csvEscapingAndEncoding()
+ {
+ const auto csv = DeviceBomExport::toCsv({{sample(), "F-03"}});
+ const QByteArray header("\xEF\xBB\xBF\"No\",\"Device tag\",\"Manufacturer\",\"Part number\","
+ "\"Model\",\"Description\",\"Category\",\"Quantity\",\"Page\",\"Notes\"\r\n");
+ QVERIFY(csv.startsWith(header));
+ QVERIFY(csv.contains(sample().m_manufacturer.toUtf8()));
+ QVERIFY(csv.contains("\"001234\""));
+ QVERIFY(!csv.contains("24 V DC"));
+ QVERIFY(!csv.contains("16 A"));
+ QVERIFY(csv.endsWith("\"2.5\",\"F-03\",\"Panel A, \"\"spare\"\"; check\nSecond line\"\r\n"));
+ QCOMPARE(DeviceBomExport::toCsv({}), header);
+ }
+
+ void fileSuccessAndFailure()
+ {
+ QTemporaryDir temp;
+ QETProject project;
+ QString error = "old error";
+ QVERIFY(!DeviceBomExport::writeCsv(project, temp.filePath("absent/bom.csv"), &error));
+ QVERIFY(!error.isEmpty());
+ const auto path = temp.filePath("bom.csv");
+ QVERIFY(DeviceBomExport::writeCsv(project, path, &error));
+ QVERIFY(error.isEmpty());
+ QVERIFY(DeviceBomExport::writeCsv(project, path, &error));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ QCOMPARE(file.readAll(), DeviceBomExport::toCsv({}));
+ }
+
+ void propertyEditorUndoRedo()
+ {
+ QETProject project;
+ auto *diagram = project.addNewDiagram();
+ auto *element = addElement(project, diagram, "-K1");
+ QVERIFY(element);
+ ElementInfoWidget editor(element);
+ const auto context = sample().toContext();
+ const auto widgets = editor.findChildren();
+ for (const QString &key : context.keys()) {
+ auto it = std::find_if(widgets.begin(), widgets.end(), [&key](ElementInfoPartWidget *widget) {
+ return widget->key() == key;
+ });
+ QVERIFY2(it != widgets.end(), qPrintable(key));
+ (*it)->setText(context.value(key).toString().replace('\n', ' '));
+ }
+ const int undo_count = diagram->undoStack().count();
+ editor.apply();
+ QCOMPARE(element->elementInformations().value("model").toString(), QString("RX-24"));
+ QCOMPARE(diagram->undoStack().count(), undo_count + 1);
+ diagram->undoStack().undo();
+ QVERIFY(element->elementInformations().value("model").toString().isEmpty());
+ diagram->undoStack().redo();
+ QCOMPARE(element->elementInformations().value("voltage_rating").toString(), QString("24 V DC"));
+ QCOMPARE(DeviceBomExport::collect(project).first().m_device.m_model, QString("RX-24"));
+ editor.resize(600, 750);
+ editor.show();
+ QCoreApplication::processEvents();
+ const QString artifacts = qEnvironmentVariable("QET_SMART_DEVICE_ARTIFACT_DIR");
+ if (!artifacts.isEmpty()) {
+ QVERIFY(editor.grab().save(QDir(artifacts).filePath("properties.png")));
+ }
+ }
+
+ void existingDatabaseReports()
+ {
+ QETProject project;
+ auto *element = addElement(project, project.addNewDiagram(), "-K1");
+ QVERIFY(element);
+ element->setElementInformations(sample().toContext());
+ project.dataBase()->updateDB();
+ auto query = project.dataBase()->newQuery(QStringLiteral(
+ "SELECT model,category,voltage_rating,current_rating,notes FROM element_nomenclature_view"));
+ QVERIFY2(query.exec(), qPrintable(query.lastError().text()));
+ QVERIFY(query.next());
+ QCOMPARE(query.value(0).toString(), sample().m_model);
+ QCOMPARE(query.value(1).toString(), sample().m_category);
+ QCOMPARE(query.value(2).toString(), sample().m_voltage_rating);
+ QCOMPARE(query.value(3).toString(), sample().m_current_rating);
+ QCOMPARE(query.value(4).toString(), sample().m_notes);
+ }
+};
+
+QTEST_MAIN(SmartDeviceTest)
+#include "tst_smart_device.moc"