diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index 7c8c3a323..8b2d39666 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -426,8 +426,12 @@ void Conductor::generateConductorPath(const QPointF &p1, Qet::Orientation o1, co if ((ori_depart == Qet::North && (ori_arrivee == Qet::South || ori_arrivee == Qet::West)) || (ori_depart == Qet::East && ori_arrivee == Qet::West)) { // cas "3": bridge is vertical, at a shared x between depart.y() // and arrivee.y() -- unnecessary exactly when depart and - // arrivee already share an x. - if (depart.x() != arrivee.x()) { + // arrivee already share an x. Compared qRound()ed, the same + // rounding the bridge coordinate itself is computed with below: + // an exact != would miss a pair that's already grid-equal after + // rounding but off by a sub-pixel remainder, and still route a + // degenerate bridge for it. + if (qRound(depart.x()) != qRound(arrivee.x())) { int ligne_inter_x = qRound(depart.x() + arrivee.x()) / 2; while (ligne_inter_x % Diagram::xGrid) -- ligne_inter_x; points << QPointF(ligne_inter_x, depart.y()); @@ -439,7 +443,7 @@ void Conductor::generateConductorPath(const QPointF &p1, Qet::Orientation o1, co // and arrivee already share a y. (Always true in this branch, // since "descendant" requires depart.y() < arrivee.y() strictly // -- kept for symmetry with the "montant" branch below.) - if (depart.y() != arrivee.y()) { + if (qRound(depart.y()) != qRound(arrivee.y())) { int ligne_inter_y = qRound(depart.y() + arrivee.y()) / 2; while (ligne_inter_y % Diagram::yGrid) -- ligne_inter_y; points << QPointF(depart.x(), ligne_inter_y); @@ -457,8 +461,9 @@ void Conductor::generateConductorPath(const QPointF &p1, Qet::Orientation o1, co // depart.x() and arrivee.x() -- unnecessary exactly when depart // and arrivee already share a y (the West->East case // diagnosed for #734: two stubs extended onto the same y run - // straight into each other, no bridge needed). - if (depart.y() != arrivee.y()) { + // straight into each other, no bridge needed). Compared + // qRound()ed, same as the other three guards. + if (qRound(depart.y()) != qRound(arrivee.y())) { int ligne_inter_y = qRound(depart.y() + arrivee.y()) / 2; while (ligne_inter_y % Diagram::yGrid) -- ligne_inter_y; points << QPointF(depart.x(), ligne_inter_y); @@ -468,7 +473,7 @@ void Conductor::generateConductorPath(const QPointF &p1, Qet::Orientation o1, co // cas "4": bridge is vertical, at a shared x between depart.y() // and arrivee.y() -- unnecessary exactly when depart and // arrivee already share an x. - if (depart.x() != arrivee.x()) { + if (qRound(depart.x()) != qRound(arrivee.x())) { int ligne_inter_x = qRound(depart.x() + arrivee.x()) / 2; while (ligne_inter_x % Diagram::xGrid) -- ligne_inter_x; points << QPointF(ligne_inter_x, depart.y()); diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e891d5aac..907fbac0b 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -120,3 +120,20 @@ add_executable( add_test(NAME tst_menubarkeyboard COMMAND tst_menubarkeyboard) target_include_directories(tst_menubarkeyboard PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_menubarkeyboard PRIVATE Qt::Test Qt::Widgets) + +# Bugtracker #734 regression: exports fixtures/qet_bug_repro_resaved.qet +# (the report's own canonical reproduction) via the built qelectrotech +# binary's --export-svg and checks no conductor path is self-retracing. +# Runs the real binary rather than linking conductor.cpp directly, since +# Conductor::generateConductorPath() pulls in the full graphics-item +# dependency graph (Diagram, Element, Terminal...) that the other, +# deliberately header-only tests above avoid. +add_executable( + tst_conductorselfretrace + tst_conductorselfretrace.cpp) +add_test(NAME tst_conductorselfretrace COMMAND tst_conductorselfretrace) +add_dependencies(tst_conductorselfretrace qelectrotech) +target_include_directories(tst_conductorselfretrace PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_conductorselfretrace PRIVATE Qt::Test) +target_compile_definitions(tst_conductorselfretrace PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") diff --git a/tests/qttest/fixtures/qet_bug_repro_resaved.qet b/tests/qttest/fixtures/qet_bug_repro_resaved.qet new file mode 100644 index 000000000..cfa24ba9d --- /dev/null +++ b/tests/qttest/fixtures/qet_bug_repro_resaved.qet @@ -0,0 +1,999 @@ + + + + ENA006 SLD probe title block � generated. + + + + + Z + + + + + + 2026-08-11 + + + + + E-110: G4 CLOSED - LEVITON B3277-YB SIDE-MOUNT SPD + 3P 20 A CB-SPD + + + + + CC + + + + + + PRELIMINARY + + + + + NOT FOR CONSTRUCTION + + + + + Y + + + + + + 2026-08-11 + + + + + BREAKER AUX/POSITION DELETED - s60 GOVERNS; G6 CLOSED-DEL; A-5 RETIRED + + + + + CC + + + + + + + + + + + + X + + + + + + 2026-08-11 + + + + + COUNCIL REVIEW - WRAP/REFS/T3/TEE; KEY-2 PAWL; G14 ACCESS; H-OTD + + + + + CC + + + + + + + + + + + + MEDIUM VOLTAGE POWER ELECTRONICS LABORATORY � UNIVERSITY OF CALGARY, SCHULICH SCHOOL OF ENGINEERING + + + + + %plant + + + + + + %locmach + + + + + + %indexrev + + + + + + %folio + + + + + + %title + + + + + + %author + + + + + + %date + + + + + + NTS + + + + + + D + + + + + + ENA006 MV TEST FACILITY � ENG A 006 + + + + + + PILOT ARTIFACT + + + + + NOT A DELIVERABLE + + + + + + + 8/12/2026 + 12-08-2026 + 2026-08-12 + qet_bug_repro + C:/Users/phili/.claude/skills/cabinet-drawings/pilot_qet/out/qet_bug_repro.qet + 19:09 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PANEL A - 480Y/277 V, 3PH, 4W, 225 A BUS + + + 175 A MAIN + + + + + + + + + + + + + + TRANSFER BUS - 200 A, 3W + PE [CABINET C] + + + 160 A CONT. TEST ENVELOPE + + + + + + + + + + + + + T-MAIN + + + 112.5 kVA DRY-TYPE + + + 600 V / 480Y-277 V + + + SEPARATELY DERIVED + + + + + + + + + + + + + CB-MAIN + + + 175 A I-LINE + + + MAIN [A] + + + + + + + + + + + + + F-VAR + + + 40 A OUTPUT + + + FUSES [B] + + + + + + + + + + + + + + + + + + + + + + UV + + + R-UV + + + 3x RM35UA13MW + + + WIPER-UV L-L AT T3 + + + FUSE LOAD SIDE + + + (E-111/E-112-2) + + + + + + + + + + + + + OL + + + OL-BR + + + 3RR2443 BRUSH + + + MONITOR [C] + + + + + + + + + + + + + Kvar + + + LC1D50A + + + + MIRROR [C] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + v2_busstub_n + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_busstub_s + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_utility + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + v2_disconnect + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + v2_transformer_2w_primary-delta_secondary-wye_g + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + + + + + + + + + v2_circuit_breaker + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_tee_l + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_spd_lv_tape + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + v2_box_h-8_text-LS_w-10 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_variac + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + v2_fuse + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + v2_box_h-8_text-UV_w-10_tape + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + v2_overload + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_no_contact + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_resistor + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_box_h-11_text-ITECH_w-18 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_rectifier + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + v2_load_arrow + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_box_h-9_text-s60_w-12 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_earth_switch_tape + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + + + v2_transformer_2w_neutral-out_primary-delta_secondary-wye + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + + + + + + + v2_relay_func-50G-51G + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_box_dashed-True_h-9_text-MVGND_w-15 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_box_h-12_text-DUT_w-20 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_box_h-8_text-TF_w-10_tape + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + v2_ct + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + v2_meter_letter-MFM + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + v2_box_h-10_text-RCPT_w-18 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_earth_switch + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + + + + + + + v2_box_h-10_text-LVPNL_w-18 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + + v2_box_dashed-True_h-9_text-AVT_w-12_tape + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + v2_box_h-11_text-DUT_w-18 + + + ENA006 SLD probe element � original NEMA/IEEE-styled geometry. PILOT ARTIFACT. + + + + + + + + + + + diff --git a/tests/qttest/tst_conductorselfretrace.cpp b/tests/qttest/tst_conductorselfretrace.cpp new file mode 100644 index 000000000..a57f229f4 --- /dev/null +++ b/tests/qttest/tst_conductorselfretrace.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include +#include +#include +#include + +namespace { + +struct Point { + double x = 0.0; + double y = 0.0; + bool operator==(const Point &other) const { return x == other.x && y == other.y; } +}; + +// Mirrors count_retraces.py (qelectrotech-docker), which this project's own +// regression checks for bugtracker #734 already use: restricted to the +// conductor path shape (4-6 points, every segment axis-aligned, not closed) +// so legitimate closed element-graphics shapes (rectangles, polygons) are +// never mistaken for a self-retracing conductor. +QList parsePoints(const QString &d) +{ + QList points; + static const QRegularExpression re(QStringLiteral("[ML]\\s*(-?\\d+\\.?\\d*)[ ,](-?\\d+\\.?\\d*)")); + auto it = re.globalMatch(d); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + points << Point{m.captured(1).toDouble(), m.captured(2).toDouble()}; + } + return points; +} + +bool isConductorShaped(const QList &points) +{ + if (points.size() < 4 || points.size() > 6) return false; + if (points.first() == points.last()) return false; // closed shape, not a conductor + for (int i = 0; i + 1 < points.size(); ++i) { + if (points.at(i).x != points.at(i + 1).x && points.at(i).y != points.at(i + 1).y) + return false; // not axis-aligned + } + return true; +} + +bool isSelfRetracing(const QList &points) +{ + // (a) skip-one out-and-back: ... A B A ... + for (int i = 0; i + 2 < points.size(); ++i) { + if (points.at(i) == points.at(i + 2)) return true; + } + // (b) immediate duplicate vertex: ... A A ... + for (int i = 0; i + 1 < points.size(); ++i) { + if (points.at(i) == points.at(i + 1)) return true; + } + // (c) 6-point crossed-stub loop: depart0 == arrivee AND depart == arrivee0 + if (points.size() == 6 && points.at(0) == points.at(4) && points.at(1) == points.at(5)) return true; + return false; +} + +} // namespace + +// Bugtracker #734: Conductor::generateConductorPath()'s cas "3"/"4" grid- +// snap bridge used to route an unnecessary out-and-back excursion, or a +// small looping detour, when depart and arrivee already shared the axis the +// bridge would run along. This fixture (the report's own canonical +// reproduction, "qet_bug_repro_resaved.qet") is known to trigger it. +class tst_conductorselfretrace : public QObject +{ + Q_OBJECT + +private slots: + void noSelfRetracingPaths() + { + const QString binary = QStringLiteral(QET_TEST_BINARY_PATH); + QVERIFY2(QFile::exists(binary), qPrintable(QStringLiteral("qelectrotech binary not found at '%1'").arg(binary))); + + const QString fixture = QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet"); + QVERIFY2(!fixture.isEmpty(), "fixture project not found"); + + QTemporaryDir out_dir; + QVERIFY(out_dir.isValid()); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(binary, {QStringLiteral("--export-svg"), fixture, out_dir.path()}); + QVERIFY2(proc.waitForFinished(30000), "export --export-svg timed out or failed to start"); + QCOMPARE(proc.exitCode(), 0); + + QDir dir(out_dir.path()); + const QStringList svgs = dir.entryList({QStringLiteral("*.svg")}, QDir::Files); + QVERIFY2(!svgs.isEmpty(), "no SVG exported"); + + static const QRegularExpression path_re(QStringLiteral("]*\\bd=\"([^\"]+)\"")); + + int retraces = 0; + for (const QString &name : svgs) { + QFile file(dir.filePath(name)); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString content = QString::fromUtf8(file.readAll()); + auto it = path_re.globalMatch(content); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + const QList points = parsePoints(m.captured(1)); + if (isConductorShaped(points) && isSelfRetracing(points)) { + ++retraces; + } + } + } + QCOMPARE(retraces, 0); + } +}; + +QTEST_APPLESS_MAIN(tst_conductorselfretrace) + +#include "tst_conductorselfretrace.moc"