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"