diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 1f9f62cd1..b3eae505e 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1790,6 +1790,11 @@ QList findTerminals(int conductor_index, if (terminal->derivedUuid() == terminal_uuid) match = terminal; } + //A terminal that moved since the project was saved: the + //uuid worked out from its old place was paired with it + //when the symbol was read (Element::fromXml()). + if (!match) + match = element->terminalFromOldPlace(terminal_uuid); if (match) found << match; else @@ -2241,6 +2246,7 @@ bool Diagram::fromXml(QDomElement &document, if (consider_informations) m_wires_not_reconnected.clear(); QList added_conductors; + int moved_ends = 0; for (auto f : QET::findInDomElement(root, QStringLiteral("conductors"), QStringLiteral("conductor"))) @@ -2312,11 +2318,19 @@ bool Diagram::fromXml(QDomElement &document, ends.join(QLatin1Char('\n')))); } added_conductors << c; + for (Terminal *end : {p1, p2}) + if (end->parentElement()->terminalWasMoved(end)) ++moved_ends; } else delete c; } } + if (consider_informations && moved_ends) { + qInfo().noquote() << "Diagram::fromXml():" + << moved_ends + << "wire end(s) on a terminal that moved since the project was saved," + << "matched to it by place"; + } if (consider_informations && !m_wires_not_reconnected.isEmpty()) { qWarning().noquote() << "Diagram::fromXml():" << m_wires_not_reconnected.size() diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index 03e600436..6e1f0b0d7 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -19,6 +19,7 @@ #include "../qetapp.h" #include "../qetproject.h" #include "../dataBase/projectdatabase.h" +#include "../ElementsCollection/terminaluuids.h" #include "../PropertiesEditor/propertieseditordialog.h" #include "../autoNum/assignvariables.h" #include "../autoNum/elementautonumschemecommand.h" @@ -972,6 +973,107 @@ bool Element::valideXml(QDomElement &e) return(true); } +/** + @brief Element::matchMovedTerminals + Pair the saved terminals that no live terminal matched exactly with the + live terminals that moved a little: the definition of the symbol was + replaced or redrawn since the project was saved. A saved record gives + the old docking point and orientation of its terminal, so it is paired + with a live terminal of the same orientation whose docking point is + within moved_terminal_reach in x and y, when each is the only candidate + of the other. Anything less clear stays unmatched, as before. + + A pair registers the record's legacy id, and the uuid TerminalUuids + derived for the terminal at its old place, so that a wire naming either + reattaches (see Diagram::findTerminals()). + @param records : the saved terminals of this symbol + @param matched_records : indexes in records already matched + @param matched_terminals : the live terminals already matched + @param id_adr : receives the legacy id of each pair +*/ +void Element::matchMovedTerminals(const QList &records, + const QSet &matched_records, + const QSet &matched_terminals, + QHash &id_adr) +{ + if (matched_records.size() == records.size()) return; + + const auto dock_of = [](const QDomElement &r) { + return QPointF(r.attribute(QStringLiteral("x")).toDouble(), + r.attribute(QStringLiteral("y")).toDouble()); + }; + const auto orientation_of = [](const QDomElement &r) { + return r.attribute(QStringLiteral("orientation")).toInt(); + }; + + QHash> candidates; // record -> live terminals in reach + QHash> claimants; // live terminal -> records in reach + for (int i = 0; i < records.size(); ++i) { + if (matched_records.contains(i)) continue; + const QPointF dock = dock_of(records.at(i)); + for (Terminal *t : std::as_const(m_terminals)) { + if (matched_terminals.contains(t)) continue; + if (static_cast(t->orientation()) != orientation_of(records.at(i))) continue; + if (qAbs(t->dock_elmt_.x() - dock.x()) > moved_terminal_reach + || qAbs(t->dock_elmt_.y() - dock.y()) > moved_terminal_reach) continue; + candidates[i] << t; + claimants[t] << i; + } + } + + for (auto it = candidates.constBegin(); it != candidates.constEnd(); ++it) { + Terminal *t = it.value().value(0); + if (it.value().size() != 1 || claimants.value(t).size() != 1) continue; + const QDomElement &record = records.at(it.key()); + const QPointF dock = dock_of(record); + + //The old place of the terminal: its docking point less the + //stub that Terminal::init() added + QPointF old_pos = dock; + switch (orientation_of(record)) { + case Qet::North: old_pos -= QPointF(0, Terminal::terminalSize); break; + case Qet::East : old_pos -= QPointF(-Terminal::terminalSize, 0); break; + case Qet::West : old_pos -= QPointF(Terminal::terminalSize, 0); break; + case Qet::South: old_pos -= QPointF(0, -Terminal::terminalSize); break; + } + //The rank among the records at the same place, as the project + //counted it when it gave the terminals their uuids + int rank = 0; + for (int j = 0; j < it.key(); ++j) { + const QPointF other = dock_of(records.at(j)); + if (orientation_of(records.at(j)) == orientation_of(record) + && qAbs(other.x() - dock.x()) < 1e-6 + && qAbs(other.y() - dock.y()) < 1e-6) + ++rank; + } + + id_adr.insert(record.attribute(QStringLiteral("id")).toInt(), t); + m_old_place_terminals.insert(TerminalUuids::derived(old_pos.x(), old_pos.y(), + orientation_of(record), rank), t); + m_moved_terminals << t; + } +} + +/** + @return the terminal that was paired with the saved terminal whose uuid + derived from its old place is @p old_place_uuid, if any + @see matchMovedTerminals() +*/ +Terminal *Element::terminalFromOldPlace(const QUuid &old_place_uuid) const +{ + return m_old_place_terminals.value(old_place_uuid, nullptr); +} + +/** + @return true if @p terminal was paired with a saved terminal at another + place than its own + @see matchMovedTerminals() +*/ +bool Element::terminalWasMoved(const Terminal *terminal) const +{ + return m_moved_terminals.contains(const_cast(terminal)); +} + /** @brief Element::fromXml Import the parameters of this element from a xml document. @@ -999,22 +1101,28 @@ bool Element::fromXml(QDomElement &e, } QHash priv_id_adr; + QSet matched_terminals; + QSet matched_records; for (auto *qgi : childItems()) { if (auto terminal_ = qgraphicsitem_cast(qgi)) { - for(auto qde : liste_terminals) + for (int i = 0; i < liste_terminals.size(); ++i) { + QDomElement qde = liste_terminals.at(i); if (terminal_ -> fromXml(qde)) { priv_id_adr.insert(qde.attribute(QStringLiteral("id")).toInt(), terminal_); + matched_terminals << terminal_; + matched_records << i; } } } } + matchMovedTerminals(liste_terminals, matched_records, matched_terminals, priv_id_adr); //Check that associated id/address doesn't conflict with table_id_adr for(auto found_id : priv_id_adr.keys()) diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index 6f5fc991d..c2bfca599 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -27,6 +27,7 @@ #include "../properties/elementdata.h" #include +#include #include #include @@ -282,6 +283,23 @@ class Element : public QetGraphicsItem DynamicElementTextItem *parseDynamicText( const QDomElement &dom_element); Terminal *parseTerminal(const QDomElement &dom_element); + void matchMovedTerminals(const QList &records, + const QSet &matched_records, + const QSet &matched_terminals, + QHash &id_adr); + + public: + Terminal *terminalFromOldPlace(const QUuid &old_place_uuid) const; + bool terminalWasMoved(const Terminal *terminal) const; + + private: + /// How far, in x and y, a terminal may have moved from where + /// a project saved it and still be recognised + static constexpr qreal moved_terminal_reach = 5.0; + /// Terminals paired with a saved one that was elsewhere, + /// and the uuid derived from that old place + QHash m_old_place_terminals; + QSet m_moved_terminals; //Reimplemented from QGraphicsItem public: diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 6166b3353..32f68efc8 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1033,6 +1033,18 @@ target_compile_definitions(tst_derivedwireuuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# A wire follows a terminal that moved a little since the project was saved +# (a symbol replaced by a newer definition), by its legacy id or by the uuid +# derived from its old place: Element::fromXml() pairs them (F112). +add_executable( + tst_movedterminals + tst_movedterminals.cpp) +add_test(NAME tst_movedterminals COMMAND tst_movedterminals) +add_dependencies(tst_movedterminals qelectrotech) +target_link_libraries(tst_movedterminals PRIVATE Qt::Test Qt::Xml) +target_compile_definitions(tst_movedterminals PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # Symbols sharing one uuid on a folio keep their wires through save and # reopen (#1408): nets of examples/Habitat-Schemas_developpes.qet. add_executable( diff --git a/tests/qttest/tst_movedterminals.cpp b/tests/qttest/tst_movedterminals.cpp new file mode 100644 index 000000000..77f5cff71 --- /dev/null +++ b/tests/qttest/tst_movedterminals.cpp @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include + +// A project saves, for every placed symbol, where each terminal docked, and +// its wires name the terminals by that number or by the uuid worked out from +// that place. When the symbol's definition has since been replaced by one +// whose terminal sits a little elsewhere, the wire must still reattach -- +// when the match is clear. +// +// Runs the real binary (--resave) on a small project written here: two +// copies of one embedded symbol, joined by one wire from the south terminal +// (the one the definition moves) to the north one (which stays). +namespace { + +const QUuid ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); + +// What TerminalUuids::derived() works out for a terminal at x, y +QString derived(int y, int orientation) +{ + return QUuid::createUuidV5(ns, QStringLiteral("%1|%2|%3") + .arg(0.0, 0, 'f', 4).arg(double(y), 0, 'f', 4).arg(orientation)) + .toString(); +} + +const QString uuid_a = QStringLiteral("{11111111-1111-4111-8111-111111111111}"); +const QString uuid_b = QStringLiteral("{22222222-2222-4222-8222-222222222222}"); + +// The symbol as the project's embedded collection now holds it. The south +// terminals are at the given y (the project was saved with one at y=10); +// the north one has always been at y=-10. +QString definition(const QList &south_ys) +{ + QString terminals = QStringLiteral( + ""); + for (int y : south_ys) + terminals += QStringLiteral( + "").arg(y); + return QStringLiteral( + "" + "" + "" + "" + "box" + "" + "%1" + "").arg(terminals); +} + +// One placed symbol as the project saved it: south terminal docked at y=6 +// (a terminal at y=10 less the 4-unit stub), north at y=-6. +QString placed(const QString &uuid, int first_id, int x) +{ + return QStringLiteral( + "" + "" + "" + "").arg(uuid).arg(x).arg(first_id).arg(first_id + 1); +} + +// The project, with the wire naming its ends as given +QString project(const QList &south_ys, const QString &wire_ends) +{ + return QStringLiteral( + "" + "" + "%1%2" + "" + "%4") + .arg(placed(uuid_a, 0, 100), placed(uuid_b, 2, 300), wire_ends, definition(south_ys)); +} + +QString byId() { return QStringLiteral("terminal1=\"0\" terminal2=\"3\""); } +QString byUuid(int old_south_y) +{ + return QStringLiteral("element1=\"%1\" terminal1=\"%2\" element2=\"%3\" terminal2=\"%4\"") + .arg(uuid_a, derived(old_south_y, 2), uuid_b, derived(-10, 0)); +} + +} // namespace + +class tst_movedterminals : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + struct Result { int wires = -1; QString log; }; + + // Open and save the project in a sandbox of its own (so a running + // QElectroTech cannot answer instead): the wires that survive. + Result resave(const QString &xml) + { + const QString in = m_dir.filePath(QStringLiteral("in%1.qet").arg(m_run)); + const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run)); + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QDir().mkpath(home); + QFile f(in); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(xml.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--resave"), in, out}); + if (!proc.waitForFinished(120000) || proc.exitCode() != 0) return {}; + Result r; + r.log = QString::fromUtf8(proc.readAllStandardError() + proc.readAllStandardOutput()); + QDomDocument doc; + QFile saved(out); + if (!saved.open(QIODevice::ReadOnly) || !doc.setContent(&saved)) return {}; + r.wires = doc.elementsByTagName(QStringLiteral("conductor")).size(); + return r; + } + + static bool said(const Result &r) + { + return r.log.contains(QStringLiteral("matched to it by place")); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + // The terminal has not moved: found as before, nothing to report. + void exactMatchIsUnaffected() + { + const Result legacy = resave(project({10}, byId())); + QCOMPARE(legacy.wires, 1); + QVERIFY(!said(legacy)); + const Result uuid = resave(project({10}, byUuid(10))); + QCOMPARE(uuid.wires, 1); + QVERIFY(!said(uuid)); + } + + void legacyIdWireFollowsATerminalMovedThreeUnits() + { + const Result r = resave(project({13}, byId())); + QCOMPARE(r.wires, 1); + QVERIFY(said(r)); + } + + void derivedUuidWireFollowsATerminalMovedThreeUnits() + { + const Result r = resave(project({13}, byUuid(10))); + QCOMPARE(r.wires, 1); + QVERIFY(said(r)); + } + + void movedFiveUnitsStillMatches() + { + QCOMPARE(resave(project({15}, byId())).wires, 1); + } + + void movedSixUnitsDoesNot() + { + QCOMPARE(resave(project({16}, byId())).wires, 0); + QCOMPARE(resave(project({16}, byUuid(10))).wires, 0); + } + + // Two live terminals in reach of one saved one: no way to tell which. + void twoCandidatesPairNothing() + { + const Result legacy = resave(project({13, 14}, byId())); + QCOMPARE(legacy.wires, 0); + QVERIFY(!said(legacy)); + QCOMPARE(resave(project({13, 14}, byUuid(10))).wires, 0); + } +}; + +QTEST_APPLESS_MAIN(tst_movedterminals) + +#include "tst_movedterminals.moc"