Merge pull request #1491 from ispyisail/fix/reattach-wires-moved-terminals

Fix wires lost when a symbol's terminal moved a few units
This commit is contained in:
ispyisail
2026-10-15 22:22:16 +13:00
committed by GitHub
5 changed files with 534 additions and 1 deletions
+14
View File
@@ -1790,6 +1790,11 @@ QList<Terminal *> 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<Conductor *> 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()
+184 -1
View File
@@ -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,182 @@ 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. Two steps, both only where the
pairing is clear:
- a rigid offset: if exactly one offset (the same for every terminal)
maps every unmatched record onto a distinct live terminal, as when a
whole drawing was moved, all are paired by it;
- otherwise per terminal: a record and a live terminal that are each
the only candidate of the other. Several records at one place and as
many live terminals at one place are paired in order.
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<QDomElement> &records,
const QSet<int> &matched_records,
const QSet<Terminal *> &matched_terminals,
QHash<int, Terminal *> &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<int, QList<Terminal *>> candidates; // record -> live terminals in reach
QHash<Terminal *, QList<int>> 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<int>(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;
}
}
//Pair a record with a live terminal: register its legacy id, and the
//uuid derived for the terminal at its old place
const auto pair_up = [&](int index, Terminal *t) {
const QDomElement &record = records.at(index);
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 < index; ++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;
};
const auto same_place = [](const QPointF &a, const QPointF &b) {
return qAbs(a.x() - b.x()) < 1e-6 && qAbs(a.y() - b.y()) < 1e-6;
};
//Rigid offset: when a whole drawing was moved, every terminal moved
//by the same amount. Try each offset seen between a record and a
//live terminal; if exactly one maps every unmatched record onto a
//distinct live terminal, the pairs are settled by it.
QList<int> unmatched;
QList<QPointF> offsets;
for (int i = 0; i < records.size(); ++i) {
if (matched_records.contains(i)) continue;
unmatched << i;
for (Terminal *t : candidates.value(i)) {
const QPointF offset = t->dock_elmt_ - dock_of(records.at(i));
const bool known = std::any_of(offsets.cbegin(), offsets.cend(),
[&](const QPointF &o) { return same_place(o, offset); });
if (!known) offsets << offset;
}
}
QList<QPair<int, Terminal *>> rigid;
int rigid_offsets = 0;
for (const QPointF &offset : std::as_const(offsets)) {
QList<QPair<int, Terminal *>> mapping;
QSet<Terminal *> used;
for (int i : std::as_const(unmatched)) {
Terminal *found = nullptr; // records at one place take the live ones in order
for (Terminal *t : candidates.value(i)) {
if (!used.contains(t)
&& same_place(t->dock_elmt_, dock_of(records.at(i)) + offset)) {
found = t;
break;
}
}
if (!found) break;
used << found;
mapping.append({i, found});
}
if (mapping.size() != unmatched.size()) continue;
++rigid_offsets;
rigid = mapping;
}
if (rigid_offsets == 1) {
for (const auto &pair : std::as_const(rigid)) pair_up(pair.first, pair.second);
return;
}
//Per terminal: a record and a live terminal that are each the only
//candidate of the other. Several records at one place with as many
//live terminals at one place pair in order.
for (int i : std::as_const(unmatched)) {
const QList<Terminal *> live = candidates.value(i);
if (live.isEmpty()) continue;
QList<int> group;
for (int j : std::as_const(unmatched)) {
if (orientation_of(records.at(j)) == orientation_of(records.at(i))
&& same_place(dock_of(records.at(j)), dock_of(records.at(i))))
group << j;
}
if (group.first() != i || live.size() != group.size()) continue;
bool clear = true;
for (Terminal *t : live) {
if (!same_place(t->dock_elmt_, live.first()->dock_elmt_)
|| claimants.value(t) != group) clear = false;
}
if (!clear) continue;
for (int k = 0; k < group.size(); ++k) pair_up(group.at(k), live.at(k));
}
}
/**
@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 *>(terminal));
}
/**
@brief Element::fromXml
Import the parameters of this element from a xml document.
@@ -999,22 +1176,28 @@ bool Element::fromXml(QDomElement &e,
}
QHash<int, Terminal *> priv_id_adr;
QSet<Terminal *> matched_terminals;
QSet<int> matched_records;
for (auto *qgi : childItems())
{
if (auto terminal_ = qgraphicsitem_cast<Terminal *>(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())
+18
View File
@@ -27,6 +27,7 @@
#include "../properties/elementdata.h"
#include <QHash>
#include <QSet>
#include <QPicture>
#include <algorithm>
@@ -282,6 +283,23 @@ class Element : public QetGraphicsItem
DynamicElementTextItem *parseDynamicText(
const QDomElement &dom_element);
Terminal *parseTerminal(const QDomElement &dom_element);
void matchMovedTerminals(const QList<QDomElement> &records,
const QSet<int> &matched_records,
const QSet<Terminal *> &matched_terminals,
QHash<int, Terminal *> &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<QUuid, Terminal *> m_old_place_terminals;
QSet<Terminal *> m_moved_terminals;
//Reimplemented from QGraphicsItem
public:
+12
View File
@@ -1033,6 +1033,18 @@ target_compile_definitions(tst_derivedwireuuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\"")
# Symbols sharing one uuid on a folio keep their wires through save and
# reopen (#1408): nets of examples/Habitat-Schemas_developpes.qet.
add_executable(
+306
View File
@@ -0,0 +1,306 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QUuid>
// 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<int> &south_ys)
{
QString terminals = QStringLiteral(
"<terminal orientation=\"n\" type=\"Generic\" name=\"n\" x=\"0\" y=\"-10\"/>");
for (int y : south_ys)
terminals += QStringLiteral(
"<terminal orientation=\"s\" type=\"Generic\" name=\"s\" x=\"0\" y=\"%1\"/>").arg(y);
return QStringLiteral(
"<collection><category name=\"import\"><category name=\"t\">"
"<element name=\"box.elmt\">"
"<definition width=\"30\" hotspot_y=\"21\" version=\"0.100.0\" type=\"element\""
" link_type=\"simple\" hotspot_x=\"15\" height=\"42\">"
"<uuid uuid=\"{33333333-3333-4333-8333-333333333333}\"/>"
"<names><name lang=\"en\">box</name></names>"
"<kindInformations/><informations/>"
"<description><rect x=\"-5\" y=\"-6\" width=\"10\" height=\"12\" antialias=\"true\""
" style=\"line-style:normal;line-weight:normal;filling:none;color:black\"/>%1</description>"
"</definition></element></category></category></collection>").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(
"<element orientation=\"0\" type=\"embed://import/t/box.elmt\" z=\"10\""
" uuid=\"%1\" x=\"%2\" y=\"200\"><terminals>"
"<terminal orientation=\"2\" x=\"0\" y=\"6\" id=\"%3\"/>"
"<terminal orientation=\"0\" x=\"0\" y=\"-6\" id=\"%4\"/>"
"</terminals></element>").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<int> &south_ys, const QString &wire_ends)
{
return QStringLiteral(
"<project title=\"moved\" version=\"0.100.1\">"
"<diagram title=\"f\" cols=\"8\" rows=\"4\" colsize=\"60\" rowsize=\"80\">"
"<elements>%1%2</elements>"
"<conductors><conductor %3 type=\"multi\" num=\"\"/></conductors>"
"</diagram>%4</project>")
.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));
}
// A symbol with any terminals, for the cases of several terminals moved at
// once. A terminal is its orientation letter and its position.
struct Term { QChar o; int x; int y; };
QString multiDefinition(const QList<Term> &terms)
{
QString out;
for (const Term &t : terms)
out += QStringLiteral("<terminal orientation=\"%1\" type=\"Generic\" name=\"t\""
" x=\"%2\" y=\"%3\"/>").arg(t.o).arg(t.x).arg(t.y);
QString d = definition({});
// the lone north terminal of definition() is replaced
d.replace(QRegularExpression(QStringLiteral("<terminal [^>]*/>")), QString());
return d.replace(QStringLiteral("</description>"), out + QStringLiteral("</description>"));
}
// A placed symbol whose terminals were saved at the given positions (ids
// first_id, first_id + 1, ...). The saved docking point is the position less
// the stub of 4: north y+4, south y-4, east x-4, west x+4.
QString multiPlaced(const QString &uuid, int first_id, int x, const QList<Term> &saved)
{
QString out;
int id = first_id;
for (const Term &t : saved) {
int dx = t.x, dy = t.y, o = 0;
switch (t.o.toLatin1()) {
case 'n': dy += 4; o = 0; break;
case 'e': dx -= 4; o = 1; break;
case 's': dy -= 4; o = 2; break;
default : dx += 4; o = 3; break;
}
out += QStringLiteral("<terminal orientation=\"%1\" x=\"%2\" y=\"%3\" id=\"%4\"/>")
.arg(o).arg(dx).arg(dy).arg(id++);
}
return QStringLiteral(
"<element orientation=\"0\" type=\"embed://import/t/box.elmt\" z=\"10\""
" uuid=\"%1\" x=\"%2\" y=\"200\"><terminals>%3</terminals></element>")
.arg(uuid).arg(x).arg(out);
}
// Two copies of the symbol saved with terminals `saved`, now defined with
// `now`; one wire joins each terminal of the first copy to the same terminal
// of the second (by legacy id).
QString multiProject(const QList<Term> &saved, const QList<Term> &now)
{
QString wires;
for (int i = 0; i < saved.size(); ++i)
wires += QStringLiteral("<conductor terminal1=\"%1\" terminal2=\"%2\" type=\"multi\" num=\"\"/>")
.arg(i).arg(saved.size() + i);
return QStringLiteral(
"<project title=\"moved\" version=\"0.100.1\">"
"<diagram title=\"f\" cols=\"8\" rows=\"4\" colsize=\"60\" rowsize=\"80\">"
"<elements>%1%2</elements><conductors>%3</conductors></diagram>%4</project>")
.arg(multiPlaced(uuid_a, 0, 100, saved),
multiPlaced(uuid_b, saved.size(), 300, saved),
wires, multiDefinition(now));
}
} // namespace
class tst_movedterminals : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
struct Result { int wires = -1; QString log; QStringList pairs; };
// 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();
for (int i = 0; i < r.wires; ++i) {
const QDomElement c = doc.elementsByTagName(QStringLiteral("conductor")).at(i).toElement();
r.pairs << c.attribute(QStringLiteral("terminal1")) + QLatin1Char('>') + c.attribute(QStringLiteral("terminal2"));
}
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);
}
// A whole drawing moved by 5: two terminals 10 apart, so each saved
// one has two live terminals at 5 units. Only one shift fits both.
void wholeDrawingMovedPairsBothTerminals()
{
const Result r = resave(multiProject({{'n', -5, -10}, {'n', 5, -10}},
{{'n', -10, -10}, {'n', 0, -10}}));
QCOMPARE(r.wires, 2);
QVERIFY(said(r));
}
// Two terminals at one place, moved by 1: paired in order.
void terminalsAtOnePlaceKeepTheirOrder()
{
const QList<Term> saved = {{'n', 1, -40}, {'n', 1, -40}};
const Result r = resave(multiProject(saved, {{'n', 0, -40}, {'n', 0, -40}}));
QCOMPARE(r.wires, 2);
QVERIFY(said(r));
// each wire runs between the same terminal of both copies, and the
// first saved one took the first live one (the rank-0 uuid)
QCOMPARE(r.pairs.size(), 2);
QCOMPARE(r.pairs.at(0), derived(-40, 0) + QLatin1Char('>') + derived(-40, 0));
QVERIFY(r.pairs.at(1) != r.pairs.at(0));
const QStringList second = r.pairs.at(1).split(QLatin1Char('>'));
QCOMPARE(second.at(0), second.at(1));
}
// Terminals that moved by different amounts have no common shift:
// each is still paired by itself.
void differentShiftsStillPairPerTerminal()
{
const Result r = resave(multiProject({{'n', -20, -10}, {'n', 20, -10}},
{{'n', -18, -10}, {'n', 23, -10}}));
QCOMPARE(r.wires, 2);
}
// A terminal that moved while another did not leaves a lone saved
// terminal: no rigid shift is needed, the per-terminal rule pairs it.
void oneMovedAmongStaysStillPairs()
{
QCOMPARE(resave(multiProject({{'n', -20, -10}, {'n', 20, -10}},
{{'n', -20, -10}, {'n', 23, -10}})).wires, 2);
}
// Still ambiguous: the two saved terminals could each be either live
// one, and no single shift maps both.
void ambiguousStillRefuses()
{
QCOMPARE(resave(multiProject({{'n', 0, -10}, {'n', 4, -10}},
{{'n', 2, -10}, {'n', 6, -10}, {'n', -2, -10}})).wires, 0);
}
};
QTEST_APPLESS_MAIN(tst_movedterminals)
#include "tst_movedterminals.moc"