Files
qelectrotech-source-mirror/tests/qttest/tst_wirehops.cpp
T
ispyisail 79a8637251 Add optional hops where wires cross (#436)
Where two wires cross without being connected, a project can now draw a
small arc (a hop) on one of them, instead of the user inserting a jump
symbol and splitting the wire. It is a project setting, off by default:
Project properties > Général > "Croisements de conducteurs", with no
hops, hops on horizontal wires, or hops on vertical wires. Choosing the
orientation rather than following drawing order keeps a project
consistent.

Only the drawing changes: no element is added and no wire is split. A
wire that ends or bends on another one is a junction and never hops,
and a crossing too close to the end of a segment for the arc to fit is
drawn plainly. PDF, SVG and image export and printing show the hops,
since they paint through Conductor::paint(); DXF export, which writes
the segments itself, does not.

The setting is saved as <wire_crossings hop="..."/> under the project
root, next to <usage>, and only when hops are on, so a project that
never used them saves exactly as before.

The geometry is in wirehops.cpp, free of any graphics item, and tested
on its own. Conductor::paintedPath() feeds it the conductors of the
folio from a snapshot of their scene points, rebuilt only when a
conductor changes shape, moves, or enters or leaves a folio; each hop
path is cached the same way. Asking the scene for the conductors in a
rect instead strokes every candidate's shape, which made a 366-wire
folio render 0.6 s slower; with the snapshot the difference is within
measurement noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:18:12 +13:00

193 lines
7.6 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
#include "wirehops.h"
// Hops where two wires cross (issue #436). The geometry is tested on its
// own; the project setting is tested through the real binary: --resave
// must keep a <wire_crossings> element, and must not add one to a project
// that never had it, so existing projects save exactly as before.
class tst_wirehops : public QObject
{
Q_OBJECT
using Mode = WireHops::Mode;
QTemporaryDir m_dir;
int m_run = 0;
static QVector<QPointF> line(QPointF a, QPointF b) { return {a, b}; }
// Runs --resave on @p in, returns the saved file's text (empty on failure)
QString resave(const QString &in)
{
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run));
const QString tmp = m_dir.filePath(QStringLiteral("tmp%1").arg(m_run));
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run++));
QDir().mkpath(home);
QDir().mkpath(tmp);
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("/.local/share"));
env.insert(QStringLiteral("TMPDIR"), tmp);
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0)
return {};
QFile file(out);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return {};
return QString::fromUtf8(file.readAll());
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
}
void modeNames()
{
QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::Horizontal))), int(Mode::Horizontal));
QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::Vertical))), int(Mode::Vertical));
QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::None))), int(Mode::None));
QCOMPARE(int(WireHops::fromString(QStringLiteral("diagonal"))), int(Mode::None));
QCOMPARE(int(WireHops::fromString(QString())), int(Mode::None));
}
void crossing_data()
{
QTest::addColumn<QVector<QPointF>>("wire");
QTest::addColumn<QVector<QPointF>>("other");
QTest::addColumn<int>("mode");
QTest::addColumn<QList<QPointF>>("expected");
const auto horizontal = line({0, 0}, {100, 0});
const auto vertical = line({50, -20}, {50, 20});
const int h = int(Mode::Horizontal), v = int(Mode::Vertical), none = int(Mode::None);
QTest::newRow("off") << horizontal << vertical << none << QList<QPointF>();
QTest::newRow("horizontal hops") << horizontal << vertical << h << QList<QPointF>{{50, 0}};
QTest::newRow("horizontal mode, vertical wire") << vertical << horizontal << h << QList<QPointF>();
QTest::newRow("vertical hops") << vertical << horizontal << v << QList<QPointF>{{50, 0}};
QTest::newRow("vertical mode, horizontal wire") << horizontal << vertical << v << QList<QPointF>();
QTest::newRow("reversed wire") << line({100, 0}, {0, 0}) << vertical << h << QList<QPointF>{{50, 0}};
QTest::newRow("T: other ends on the wire") << horizontal << line({50, 0}, {50, 20}) << h << QList<QPointF>();
QTest::newRow("T: other starts on the wire") << horizontal << line({50, -20}, {50, 0}) << h << QList<QPointF>();
QTest::newRow("too near the wire's end") << horizontal << line({2, -20}, {2, 20}) << h << QList<QPointF>();
QTest::newRow("parallel") << horizontal << line({0, 10}, {100, 10}) << h << QList<QPointF>();
QTest::newRow("misses") << horizontal << line({150, -20}, {150, 20}) << h << QList<QPointF>();
QTest::newRow("bent wire, second segment")
<< QVector<QPointF>{{0, -40}, {0, 0}, {100, 0}} << vertical << h << QList<QPointF>{{50, 0}};
}
void crossing()
{
QFETCH(QVector<QPointF>, wire);
QFETCH(QVector<QPointF>, other);
QFETCH(int, mode);
QFETCH(QList<QPointF>, expected);
QCOMPARE(WireHops::crossings(wire, {other}, Mode(mode)), expected);
}
void closeCrossings()
{
const auto wire = line({0, 0}, {100, 0});
// 6 apart: the arcs would overlap, so only the first hops
QCOMPARE(WireHops::crossings(wire, {line({40, -20}, {40, 20}), line({46, -20}, {46, 20})}, Mode::Horizontal),
(QList<QPointF>{{40, 0}}));
// 10 apart, one grid step: both hop, in the wire's order
QCOMPARE(WireHops::crossings(wire, {line({60, -20}, {60, 20}), line({50, -20}, {50, 20})}, Mode::Horizontal),
(QList<QPointF>{{50, 0}, {60, 0}}));
}
void pathWithoutHopsIsThePlainWire()
{
const QVector<QPointF> wire{{0, -40}, {0, 0}, {100, 0}};
QPainterPath plain;
plain.moveTo(wire.at(0));
plain.lineTo(wire.at(1));
plain.lineTo(wire.at(2));
QCOMPARE(WireHops::path(wire, {}, Mode::Horizontal), plain);
QCOMPARE(WireHops::path(wire, {{50, 0}}, Mode::None), plain);
}
void pathArcs_data()
{
QTest::addColumn<QVector<QPointF>>("wire");
QTest::addColumn<int>("mode");
QTest::addColumn<QRectF>("bounds");
const qreal r = WireHops::radius;
// The arc goes above a horizontal wire, right of a vertical one,
// whichever way the wire runs
QTest::newRow("left to right") << line({0, 0}, {100, 0}) << int(Mode::Horizontal)
<< QRectF(0, -r, 100, r);
QTest::newRow("right to left") << line({100, 0}, {0, 0}) << int(Mode::Horizontal)
<< QRectF(0, -r, 100, r);
QTest::newRow("top to bottom") << line({50, -20}, {50, 20}) << int(Mode::Vertical)
<< QRectF(50, -20, r, 40);
QTest::newRow("bottom to top") << line({50, 20}, {50, -20}) << int(Mode::Vertical)
<< QRectF(50, -20, r, 40);
}
void pathArcs()
{
QFETCH(QVector<QPointF>, wire);
QFETCH(int, mode);
QFETCH(QRectF, bounds);
const QPointF hop(50, 0);
const QPainterPath path = WireHops::path(wire, {hop}, Mode(mode));
const QRectF actual = path.boundingRect();
QVERIFY2(qAbs(actual.left() - bounds.left()) < 0.01 && qAbs(actual.top() - bounds.top()) < 0.01
&& qAbs(actual.right() - bounds.right()) < 0.01 && qAbs(actual.bottom() - bounds.bottom()) < 0.01,
qPrintable(QStringLiteral("bounds %1,%2 %3x%4").arg(actual.x()).arg(actual.y())
.arg(actual.width()).arg(actual.height())));
QCOMPARE(path.pointAtPercent(0), wire.first());
QCOMPARE(path.pointAtPercent(1), wire.last());
}
void savedSettingSurvivesResave()
{
const QString fixture = QFINDTESTDATA("fixtures/wiring_list_arrows.qet");
QVERIFY2(!fixture.isEmpty(), "fixture project not found");
// The fixture has no setting: a resave must not add one
const QString plain = resave(fixture);
QVERIFY2(!plain.isEmpty(), "--resave failed");
QVERIFY(!plain.contains(QLatin1String("wire_crossings")));
// With the setting, a resave keeps it
QFile source(fixture);
QVERIFY(source.open(QIODevice::ReadOnly | QIODevice::Text));
QString text = QString::fromUtf8(source.readAll());
const int newdiagrams = text.indexOf(QLatin1String("<newdiagrams"));
QVERIFY(newdiagrams > 0);
text.insert(newdiagrams, QStringLiteral("<wire_crossings hop=\"vertical\"/>\n "));
const QString with_setting = m_dir.filePath(QStringLiteral("with_setting.qet"));
QFile out(with_setting);
QVERIFY(out.open(QIODevice::WriteOnly | QIODevice::Text));
out.write(text.toUtf8());
out.close();
const QString saved = resave(with_setting);
QVERIFY2(!saved.isEmpty(), "--resave failed");
QVERIFY2(saved.contains(QLatin1String("<wire_crossings hop=\"vertical\"/>")),
"the setting was lost on save");
}
};
QTEST_GUILESS_MAIN(tst_wirehops)
#include "tst_wirehops.moc"