From eb2367e400a55e38c00cd590a295207d90a57fba Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 22:19:39 +1300 Subject: [PATCH] Put a potential's one text on the same conductor in every run With "one text per potential" on, the number is drawn on the potential's longest conductor. When two conductors tie for longest, longestConductorInPotential() kept whichever it met first, iterating a QSet in pointer order -- which changes from run to run. The same file, exported twice, could put the number on either conductor: same numbers, different PDF. A tie is now broken by where the conductors' ends are on the folio, then by uuid. Not by uuid first: a file with no conductor uuids gets fresh ones on every load, and a project generated again by a script gets new ones each time, while the drawing is the same. tst_potentialtextcarrier exports a fixture with such a tie eight times; before the fix it failed in each of three tries. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/qetgraphicsitem/conductor.cpp | 50 ++- tests/qttest/CMakeLists.txt | 13 + .../fixtures/one_text_per_potential_tie.qet | 320 ++++++++++++++++++ tests/qttest/tst_potentialtextcarrier.cpp | 81 +++++ 4 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 tests/qttest/fixtures/one_text_per_potential_tie.qet create mode 100644 tests/qttest/tst_potentialtextcarrier.cpp diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index 4d658129f..0d2faa2f2 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -2311,12 +2311,58 @@ QPointF Conductor::movePointIntoPolygon(const QPointF &point, const QPainterPath @param all_diagram : true -> search in the whole project, false -> search only in the diagram of conductor @return the longest conductor in the same potential of conductor */ +namespace { + /// The two ends of @p conductor on the folio, smaller first, compared + /// by x then y. + QPair conductorEnds(const Conductor *conductor) + { + auto less = [](const QPointF &a, const QPointF &b) { + return a.x() < b.x() || (a.x() == b.x() && a.y() < b.y()); + }; + QPointF a = conductor->terminal1 ? conductor->terminal1->dockConductor() : QPointF(); + QPointF b = conductor->terminal2 ? conductor->terminal2->dockConductor() : QPointF(); + if (less(b, a)) + std::swap(a, b); + return qMakePair(a, b); + } + + /// True if @p a comes before @p b in an order that is the same in every + /// run: by where the conductor's ends are on the folio, then by uuid. + /// Not by uuid first: a project generated again by a script gets new + /// uuids each time, and a file with no conductor uuids gets new ones on + /// every load, while the drawing is the same. + bool stableConductorLess(const Conductor *a, const Conductor *b) + { + const auto ea = conductorEnds(a), eb = conductorEnds(b); + const qreal ka[4] = {ea.first.x(), ea.first.y(), ea.second.x(), ea.second.y()}; + const qreal kb[4] = {eb.first.x(), eb.first.y(), eb.second.x(), eb.second.y()}; + for (int i = 0; i < 4; ++i) + if (ka[i] != kb[i]) + return ka[i] < kb[i]; + return a->uuid() < b->uuid(); + } +} + Conductor * longestConductorInPotential(Conductor *conductor, bool all_diagram) { Conductor *longest_conductor = conductor; - //Search the longest conductor + qreal longest_length = conductor->length(); + //Search the longest conductor. + //Conductors of equal length are common (a symmetrical layout), and the + //set is iterated in pointer order, which changes from run to run: so a + //tie is broken by an order that does not, or the potential's text lands + //on a different conductor each time the same file is opened or exported. foreach (Conductor *c, conductor -> relatedPotentialConductors(all_diagram)) - if (c -> length() > longest_conductor -> length()) + { + const qreal length = c->length(); + if (qAbs(length - longest_length) < 1e-6) { + if (stableConductorLess(c, longest_conductor)) + longest_conductor = c; + } + else if (length > longest_length) { longest_conductor = c; + longest_length = length; + } + } return longest_conductor; } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b1085990..e8a75a1d3 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -388,6 +388,19 @@ target_compile_definitions(tst_wiringlistexport PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# One text per potential: the number goes on the same conductor in every +# run when two conductors tie for longest. Exports +# fixtures/one_text_per_potential_tie.qet through the real binary, several +# times, since the order it used to depend on changes only between runs. +add_executable( + tst_potentialtextcarrier + tst_potentialtextcarrier.cpp) +add_test(NAME tst_potentialtextcarrier COMMAND tst_potentialtextcarrier) +add_dependencies(tst_potentialtextcarrier qelectrotech) +target_link_libraries(tst_potentialtextcarrier PRIVATE Qt::Test) +target_compile_definitions(tst_potentialtextcarrier PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # Hops where wires cross (issue #436): the geometry in wirehops.cpp, and # through the real binary, that --resave keeps the project setting and # adds nothing to a project without it. diff --git a/tests/qttest/fixtures/one_text_per_potential_tie.qet b/tests/qttest/fixtures/one_text_per_potential_tie.qet new file mode 100644 index 000000000..31f27e377 --- /dev/null +++ b/tests/qttest/fixtures/one_text_per_potential_tie.qet @@ -0,0 +1,320 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + one_text_per_potential_tie + one_text_per_potential_tie.qet + 22:07 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + label + + + + + + + + + + + + + + label + + + + + + + + + + + + + + label + + + + + + + + + + + + + + label + + + + + + + + + + + + + + + + + + + + + Elements importats + Zavedené prvky + Importerede elementer + Importierte Elemente + Εισηγμένα στοιχεία + Imported elements + Elementos importados + Éléments importés + Uvezeni elementi + Elementi importati + インバートされた要素 + 전기 심볼 요소 + 전기 심볼 요소 + Elementen geïmporteerd + Elementen geïmporteerd + Elementy importowane + Elementos importados + Elementos importados + Elemente importate + Импортированные элементы + Uvoženi elementi + Importerade element + İthal öğeler + Імпортовані елементи + 导入元件 + + + + كهربائي + Elèctric + Elektrotechnika + Elektrisk + Elektrik + Ηλεκτρικά + Electric + Eléctrica + Sähkö + Electrique + Električno + Elektromos + Elettrica + 電気 + 전기 + Цахилгаан + Elektrisk + Elektrotechniek + Elektrisch + Elektrotechnika + Elétrico + Elétrica + Electric + Электротехника + Elektrické + Elektrika + Електрика + El + Elektrik + Електрика + 10_电气 + 电气工程 + + + + متعدد الأقطاب + Multifilar + Vícežilový + Flere ledere + Allpolig + Πολυγραμμικό + All-pole + Multifilar + Yleisnapa + Multifilaire + Univerzalni + Többvonalas szimbólumok + Multifilare + 複線 + 다-극 + Бүх туйл + Alle poler + Veel polig + All-pole + Schematy wieloliniowe + Multipolar + Multifilar + Bipolar + Многополюсные + Všepólové + Vsi poli + Свe-полни + Allpolig + Çok kutuplu + Багатополюсні + 10_全极 + + + + إلكترونيات وأشباه موصلات + Electrònica i semiconductors + Elektronika a polovodiče + Elektronik og halvledere + Elektronik und Halbleiter + Ηλεκτρονικά και ημιαγωγοί + Electronics and semiconductors + Electrónica y semiconductores + Elektroniikka ja puolijohteet + Electronique et semi-conducteurs + Elektronika i poluvodiči + Elektronika és félvezetők + Elettronica e semiconduttori + 電子と半導体 + 전자 공학 및 반도체 + Электроник ба хагас дамжуулагч + Elektronikk og halvledere + Electronica en halfgeleiders + Elektronica en halfgeleiders + Elektronika i półprzewodniki + Eletrónica e semicondutores + Eletrônica e semi-condutoues + Electronică și semiconductoare + Электроника и полупроводники + Elektronika a polovodiče + Elektronika in polprevodniki + Електроника и полупроводници + Elektronik och halvledare + Elektronik ve yarı iletkenler + Електроніка та напівпровідники + 电子与半导体 + + + + مُقاومة + Resistències + Odpory + Modstande + Widerstände + Αντιστάσεις + Resistors + Resistencias + Vastukset + Résistances + Otpornici + Ellenállások + Resistori + 抵抗器 + 저항 + Эсэргүүцэл + Motstander + Weerstand + Weerstanden + Rezystory + Resistência + Resistores + Rezistoare + Резисторы + Rezistory + Upori + Отпорници + Motstånd + Dirençler + Резистори + 电阻器 + + + + + + مُقاومة + Resistència + Odpor + Modstand + Widerstand + Αντίσταση + Resistor + Resistencia + Vastus + Résistance + Otpornik + Ellenállás + Resistore + 抵抗器 + 저항 + Резистор + Motstand + Weerstand + Weerstand + Rezystor + Resistência + Resistor + Rezistor + Резистор + Rezistor + Upor + Отпорник + Resistor + Direnç + Резистор + 电阻 + + EN 60617: 04-01-01 + + + + + + label + + + + + + + + + + + + + diff --git a/tests/qttest/tst_potentialtextcarrier.cpp b/tests/qttest/tst_potentialtextcarrier.cpp new file mode 100644 index 000000000..559ef0ce0 --- /dev/null +++ b/tests/qttest/tst_potentialtextcarrier.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include + +// With "one text per potential" on, the potential's number is drawn on its +// longest conductor. fixtures/one_text_per_potential_tie.qet has two +// conductors of the same, greatest length in one potential: which of them +// carried the number used to follow pointer order, so the same file +// exported twice could put it on either. Runs the real binary, in separate +// processes, since that order only changes from one run to the next. +class tst_potentialtextcarrier : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Export the fixture to SVG and return where the number is drawn: the + // transform of the group holding it. Empty if it is not drawn exactly + // once. + QString carrier(int run) + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(run)); + const QString out = m_dir.filePath(QStringLiteral("svg%1").arg(run)); + QDir().mkpath(home); + 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")); + env.insert(QStringLiteral("TMPDIR"), home); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--export-svg"), + QFINDTESTDATA("fixtures/one_text_per_potential_tie.qet"), out}); + if (!proc.waitForFinished(60000) || proc.exitCode() != 0) + return {}; + + const QStringList svgs = QDir(out).entryList({QStringLiteral("*.svg")}); + if (svgs.size() != 1) + return {}; + QFile f(QDir(out).filePath(svgs.first())); + if (!f.open(QIODevice::ReadOnly)) + return {}; + const QString svg = QString::fromUtf8(f.readAll()); + + static const QRegularExpression re( + QStringLiteral("transform=\"([^\"]*)\"[^<]*>\\s*]*>42")); + QStringList found; + auto it = re.globalMatch(svg); + while (it.hasNext()) + found << it.next().captured(1); + return found.size() == 1 ? found.first() : QString(); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + // Before the fix about half the runs differed from the first, so + // eight agreeing runs leave a 1 in 128 chance of a false pass. + void sameCarrierEveryRun() + { + const QString first = carrier(0); + QVERIFY2(!first.isEmpty(), "the number is not drawn exactly once"); + for (int run = 1; run < 8; ++run) + QCOMPARE(carrier(run), first); + } +}; + +QTEST_GUILESS_MAIN(tst_potentialtextcarrier) +#include "tst_potentialtextcarrier.moc"