From 353dbd33175347f4df7fe87b62a0382c3ace5209 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 21:07:16 +1300 Subject: [PATCH 01/13] Scripting: list a folio's conductors by uuid and find their ends A script could name a conductor only by one of its ends, "the conductor on terminal N of element X", which fails where two conductors meet at one terminal and cannot follow a conductor that is re-connected. qet.conductorUuids(folio) lists the folio's conductor uuids, in the order qet.conductors() lists them. qet.conductorEnds(folio, uuid) returns that conductor's two ends as "{element uuid} terminal N" -- the form conductors() prints and the conductor calls take -- or an empty list if the folio has no such conductor. The end formatting conductors() already did is shared rather than copied. Conductors of older projects have no saved uuid yet, so theirs change from one load to the next until that is settled (discussion #1103); new conductors keep theirs. tst_scriptconductoruuid runs a script through --run on a fixture: every conductor has a distinct uuid, and its ends match the conductors() line at the same position; an unknown uuid, a malformed one and a folio that does not exist give empty lists. It fails with the two ends swapped. Co-Authored-By: Claude Opus 5.5 --- sources/scripting/qetscriptapi.cpp | 62 ++++++++++++-- sources/scripting/qetscriptapi.h | 2 + tests/qttest/CMakeLists.txt | 12 +++ tests/qttest/tst_scriptconductoruuid.cpp | 104 +++++++++++++++++++++++ 4 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 tests/qttest/tst_scriptconductoruuid.cpp diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index b3d083a3f..b88d8ee17 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -844,6 +844,18 @@ bool QetScriptApi::addConductor(int folioIndex, exists so a script, or a person reading its output, can see what is there before changing it. */ +namespace { +/// "{element uuid} terminal N", the form conductors() prints an end in and +/// the conductor calls take as element uuid + terminal index. +QString describeEnd(Terminal *t) +{ + if (!t || !t->parentElement()) return QStringLiteral("?"); + return QStringLiteral("%1 terminal %2") + .arg(t->parentElement()->uuid().toString()) + .arg(t->parentElement()->terminals().indexOf(t)); +} +} // namespace + QStringList QetScriptApi::conductors(int folioIndex) const { QStringList list; @@ -851,23 +863,57 @@ QStringList QetScriptApi::conductors(int folioIndex) const const QList diagrams = m_project->diagrams(); if (folioIndex < 0 || folioIndex >= diagrams.count()) return list; - auto describe = [](Terminal *t) -> QString { - if (!t || !t->parentElement()) return QStringLiteral("?"); - return QStringLiteral("%1 terminal %2") - .arg(t->parentElement()->uuid().toString()) - .arg(t->parentElement()->terminals().indexOf(t)); - }; - DiagramContent content(diagrams.at(folioIndex), false); const QList all = content.conductors(DiagramContent::AnyConductor); for (Conductor *c : all) { list << QStringLiteral("%1 -- %2 : num='%3'") - .arg(describe(c->terminal1), describe(c->terminal2), c->properties().text); + .arg(describeEnd(c->terminal1), describeEnd(c->terminal2), c->properties().text); } return list; } +/** + @brief QetScriptApi::conductorUuids + The uuid of every conductor on the folio, in the order conductors() + lists them. +*/ +QStringList QetScriptApi::conductorUuids(int folioIndex) const +{ + QStringList list; + if (!m_project) return list; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return list; + + DiagramContent content(diagrams.at(folioIndex), false); + for (Conductor *c : content.conductors(DiagramContent::AnyConductor)) + list << c->uuid().toString(); + return list; +} + +/** + @brief QetScriptApi::conductorEnds + The two ends of the conductor carrying @p uuid, each as + "{element uuid} terminal N" -- the element uuid and terminal index the + conductor calls take -- or an empty list if the folio has no such + conductor. A uuid names one conductor even where two meet at a terminal, + which an element uuid + terminal index cannot. +*/ +QStringList QetScriptApi::conductorEnds(int folioIndex, const QString &uuid) const +{ + if (!m_project) return {}; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return {}; + const QUuid wanted(uuid); + if (wanted.isNull()) return {}; + + DiagramContent content(diagrams.at(folioIndex), false); + for (Conductor *c : content.conductors(DiagramContent::AnyConductor)) + if (c->uuid() == wanted) + return {describeEnd(c->terminal1), describeEnd(c->terminal2)}; + return {}; +} + QString QetScriptApi::conductorProperty(int folioIndex, const QString &elementUuid, int terminalIndex, const QString &property) const { diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 533b70830..edaea2e80 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -396,6 +396,8 @@ class QetScriptApi : public QObject // -- conductor properties, applied to the whole potential -- Q_INVOKABLE QStringList conductors(int folioIndex) const; + Q_INVOKABLE QStringList conductorUuids(int folioIndex) const; + Q_INVOKABLE QStringList conductorEnds(int folioIndex, const QString &uuid) const; Q_INVOKABLE QString conductorProperty(int folioIndex, const QString &elementUuid, int terminalIndex, const QString &property) const; Q_INVOKABLE bool setConductorProperty(int folioIndex, const QString &elementUuid, diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 94283cc09..64a3b8cd0 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -328,3 +328,15 @@ 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=\"$\"") + +# qet.conductorUuids() / qet.conductorEnds(): a script lists a folio's +# conductors by uuid and finds each one's two ends. Runs a script through +# the real binary's --run on fixtures/qet_bug_repro_resaved.qet. +add_executable( + tst_scriptconductoruuid + tst_scriptconductoruuid.cpp) +add_test(NAME tst_scriptconductoruuid COMMAND tst_scriptconductoruuid) +add_dependencies(tst_scriptconductoruuid qelectrotech) +target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test) +target_compile_definitions(tst_scriptconductoruuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") diff --git a/tests/qttest/tst_scriptconductoruuid.cpp b/tests/qttest/tst_scriptconductoruuid.cpp new file mode 100644 index 000000000..27792ab70 --- /dev/null +++ b/tests/qttest/tst_scriptconductoruuid.cpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// qet.conductorUuids(folio) and qet.conductorEnds(folio, uuid): a script can +// list a folio's conductors by uuid and find where each one runs, in the +// "{element} terminal N" form conductors() prints and the conductor calls +// take. Runs a script through the real binary's --run. +class tst_scriptconductoruuid : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on the fixture in a sandbox of its own and return the + // JSON object it logged. + QJsonObject run(const QString &script) + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(script.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + 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("--run"), path, + QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet")}); + if (!proc.waitForFinished(60000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void uuidsAndEndsMatchConductors() + { + const QJsonObject r = run(QStringLiteral( + "var uuids = qet.conductorUuids(0);\n" + "var ends = uuids.map(function (u) { return qet.conductorEnds(0, u); });\n" + "qet.log('PROBE ' + JSON.stringify({lines: qet.conductors(0), uuids: uuids, ends: ends,\n" + " unknown: qet.conductorEnds(0, '{00000000-0000-0000-0000-000000000001}'),\n" + " junk: qet.conductorEnds(0, 'not a uuid'),\n" + " badFolio: qet.conductorUuids(99)}));\n")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + + const QJsonArray lines = r.value(QStringLiteral("lines")).toArray(); + const QJsonArray uuids = r.value(QStringLiteral("uuids")).toArray(); + const QJsonArray ends = r.value(QStringLiteral("ends")).toArray(); + QCOMPARE(lines.size(), 7); // the fixture's conductors + QCOMPARE(uuids.size(), lines.size()); + QSet distinct; + for (int i = 0; i < uuids.size(); ++i) { + const QString u = uuids.at(i).toString(); + QVERIFY2(!QUuid(u).isNull(), qPrintable(u)); + distinct.insert(u); + // same order as conductors(), and the same two ends it prints + const QJsonArray e = ends.at(i).toArray(); + QCOMPARE(e.size(), 2); + const QString expected = e.at(0).toString() + QStringLiteral(" -- ") + + e.at(1).toString() + QStringLiteral(" : "); + QVERIFY2(lines.at(i).toString().startsWith(expected), + qPrintable(lines.at(i).toString() + QStringLiteral(" | ") + expected)); + } + QCOMPARE(distinct.size(), uuids.size()); + + QVERIFY(r.value(QStringLiteral("unknown")).toArray().isEmpty()); + QVERIFY(r.value(QStringLiteral("junk")).toArray().isEmpty()); + QVERIFY(r.value(QStringLiteral("badFolio")).toArray().isEmpty()); + } +}; + +QTEST_APPLESS_MAIN(tst_scriptconductoruuid) + +#include "tst_scriptconductoruuid.moc" From 9b967707a000a1178b349572b7500bc0dbea74c2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 21:12:55 +1300 Subject: [PATCH 02/13] qet-mcp: qet_edit names a conductor by its uuid set_conductor, move_conductor_segment and delete_conductor named a conductor by one of its terminals, which had to carry exactly one conductor: where two meet at a terminal, neither could be named from it. Each now also takes "conductor": "{uuid}" (as qet_conductors reports it) in place of element + terminal. The generated script asks qet.conductorEnds() for the conductor's two ends and passes the one whose terminal carries only that conductor. Where both ends are shared, or no conductor has that uuid, the op fails and its "note" says which. The lookup is required only when a uuid is used; giving both forms is an error. Tests: the script generated for each form and the argument errors; on a folio where one terminal carries two conductors, deleting either by uuid leaves exactly the other; an unknown uuid is reported. With the end chosen without checking its terminal carries only that conductor, the terminal test fails. 248/248 with a build carrying qet.conductorEnds(). Stacked on the conductorUuids()/conductorEnds() scripting change. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 7 ++++ misc/qet-mcp/qet_mcp.py | 64 ++++++++++++++++++++++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 54 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index e5136fb88..08317a00c 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -290,6 +290,13 @@ Python, plus the hang guard on `addConductor` and the database refresh in `index` also takes that uuid, which does not shift the way an index does. `qet_element_build` gives every part of a symbol a uuid as well, returned in `part_uuids`; `qet_element_info` lists them in `part_list`. +- **A conductor can be named by its uuid** (`qet_conductors` reports it): + `set_conductor`, `move_conductor_segment` and `delete_conductor` take + `"conductor": "{uuid}"` in place of `element` + `terminal`, which works + where two conductors meet at a terminal. It is turned at run time into an + end whose terminal carries only that conductor; where both of its ends + are shared the op fails and its `note` says why. Needs + `qet.conductorEnds()` in the build. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 7703c4c0e..0bf06a875 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1354,6 +1354,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", "indexrev", "folio", "template"] +# The ops that address one conductor by element + terminal, and so also +# take "conductor": "{uuid}" (qet_conductors reports each one's uuid). +CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor") + # Accepted by set_conductor. The names are the project file's own, so what # a script sets is what qet_conductors reports back. CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable", @@ -1405,6 +1409,34 @@ def _build_script(operations: list, output: str) -> str: f"qet.log({_js(_MARKER)} + JSON.stringify(" "{kind: 'capabilities', missing: missing}));", "var stop = false;", + # A conductor named by uuid is turned into one of its ends, as the + # conductor calls take it: an end whose terminal carries no other + # conductor, so the call cannot pick the wrong one. null, with the + # reason logged, if it is not on the folio or both ends are shared. + "function qetMcpConductorEnd(index, folio, uuid) {", + " var ends = qet.conductorEnds(folio, uuid);", + " var why = 'no conductor ' + uuid + ' on folio ' + folio;", + " if (ends && ends.length === 2) {", + " var lines = qet.conductors(folio);", + " for (var k = 0; k < 2; k++) {", + " var n = 0;", + " for (var j = 0; j < lines.length; j++) {", + " var p = lines[j].split(' : ')[0].split(' -- ');", + " if (p[0] === ends[k] || p[1] === ends[k]) n++;", + " }", + " if (n === 1) {", + " var m = ends[k].split(' terminal ');", + " return {element: m[0], terminal: parseInt(m[1], 10)};", + " }", + " }", + " why = 'conductor ' + uuid + ' shares both of its terminals with other '", + " + 'conductors; the conductor calls address one by a terminal carrying '", + " + 'only it';", + " }", + f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'op_note', index: index, note: why}));", + " return null;", + "}", "if (missing.length === 0) {", ] @@ -1561,6 +1593,20 @@ def _build_script(operations: list, output: str) -> str: if name == "add_shape" and op.get("shape") not in SHAPES: raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; " f"expected one of {', '.join(SHAPES)}") + # The conductor ops take "conductor": "{uuid}" in place of element + + # terminal: a uuid names one conductor for good, where a terminal + # can carry several. + conductor_js = None + if name in CONDUCTOR_UUID_OPS and "conductor" in op: + if "element" in op or "terminal" in op: + raise ValueError(f"operation {i} ({name}): give either \"conductor\" " + f"(its uuid) or \"element\" + \"terminal\", not both") + if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]): + raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, " + f"got {op['conductor']!r}") + conductor_js = _js(op["conductor"]) + uuid_methods.add("conductorEnds") + op = {**op, "element": "{00000000-0000-0000-0000-000000000000}", "terminal": 0} args = [] for key, kind in spec: if key not in op: @@ -1568,6 +1614,8 @@ def _build_script(operations: list, output: str) -> str: folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None args.append(ref_or(op[key], kind, i, key)) + if conductor_js is not None: + args[1], args[2] = f"e{i}.element", f"e{i}.terminal" ident = op.get("id") if ident is not None: @@ -1579,6 +1627,9 @@ def _build_script(operations: list, output: str) -> str: call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" lines.append(" if (!stop) {") + if conductor_js is not None: + lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") + call = f"(e{i} ? {call} : false)" lines.append(f" var v{i} = {call};") if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") @@ -1624,7 +1675,7 @@ def _parse_script_output(text: str) -> dict: the cost is nothing and the failure it prevents is silent (an edit that worked, reported as having run no operations at all, which is what the first version of this tool did).""" - caps, ops, saved, stopped = None, [], None, False + caps, ops, saved, stopped, notes = None, [], None, False, {} for line in text.splitlines(): idx = line.find(_MARKER) if idx < 0: @@ -1643,9 +1694,14 @@ def _parse_script_output(text: str) -> dict: rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or (isinstance(r, int) and not isinstance(r, bool) and r == -1)) ops.append(rec) + elif rec.get("kind") == "op_note": + notes[rec.get("index")] = rec.get("note") elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) + for rec in ops: + if rec.get("index") in notes: + rec["note"] = notes[rec["index"]] return {"missing_methods": caps, "operations": ops, "saved": saved, "stopped_early": stopped} @@ -2491,7 +2547,11 @@ TOOLS = [ "index in the element definition; qet_element_info lists them. " "set_conductor addresses a conductor as the one on a given " "terminal and applies the change to its whole electrical " - "potential, so name a terminal carrying exactly one conductor; " + "potential, so name a terminal carrying exactly one conductor, " + "or give \"conductor\": its uuid from qet_conductors in place " + "of \"element\" + \"terminal\" (set_conductor, " + "move_conductor_segment and delete_conductor all accept it; it " + "needs one of the conductor's two terminals to carry only it); " "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + ". move_conductor_segment reroutes the drawn path itself rather " "than a property of the potential -- addressed the same way (a " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index d9310e6c2..592dbd9b7 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -194,6 +194,26 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "index or its uuid"): self.build([op]) + def test_conductor_by_uuid(self): + """A conductor named by uuid is turned into one of its ends at run + time; the lookup is required only then, and "conductor" cannot be + given alongside element + terminal.""" + U = "{11111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "delete_conductor", "folio": 1, "conductor": U}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 1, "{U}");', s) + self.assertIn("(e0 ? qet.deleteConductor(1, e0.element, e0.terminal) : false)", s) + self.assertIn('"conductorEnds"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U, + "property": "num", "value": "W1"}]) + self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s) + self.assertNotIn('"conductorEnds"', self.build( + [{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}])) + with self.assertRaisesRegex(ValueError, "not both"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": U, + "element": U, "terminal": 0}]) + with self.assertRaisesRegex(ValueError, "must be a conductor uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2379,6 +2399,40 @@ class Integration(unittest.TestCase): r = self.ok(self.sb.edit(base, ops)) self.assertEqual([c["num"] for c in m.tool_conductors(r["output"])["conductors"]], ["W7"]) + def _hub(self): + """e0's terminal 0 carries two conductors, to e1 and to e2. Returns + the saved project and the two conductors' uuids.""" + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(3)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}])) + uuids = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(len(uuids), 2) + self.assertTrue(all(uuids), "new conductors carry a saved uuid") + return r["output"], uuids + + def test_conductor_by_uuid_where_two_meet_at_a_terminal(self): + """The terminal e0/0 carries both conductors, which element + terminal + cannot name; each uuid names one, whichever it is.""" + project, uuids = self._hub() + for gone, kept in ((uuids[0], uuids[1]), (uuids[1], uuids[0])): + with self.subTest(deleted=gone): + r = self.ok(self.sb.edit(project, [ + {"op": "delete_conductor", "folio": 1, "conductor": gone}], out="out2.qet")) + left = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(left, [kept]) + + def test_unknown_conductor_uuid_is_reported(self): + project, _ = self._hub() + r = self.sb.edit(project, [{"op": "delete_conductor", "folio": 1, + "conductor": "{11111111-2222-4333-8444-555555555555}"}], + out="out2.qet") + self.assertFalse(r["ok"]) + self.assertIn("no conductor", r["operations"][0].get("note", "")) + # ---- cross references, strips, folios ---- def test_cross_reference_across_folios(self): From 0721b42e2103705dcb2d1983fa14dad942204074 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 20:13:31 +1300 Subject: [PATCH 03/13] Give symbols saved without a uuid the same one on every load A symbol saved without a uuid got a random one from Element::fromXml() on every load, and the next save wrote it out: two loads of the same file gave the same symbol two identities, and anything pointing at it by uuid (a script, a comparison of two versions, a wire's identity) could not follow it from one session to the next. When a folio is loaded, such a symbol now gets a UUID v5 derived from what it is and where it sits: its type, its position on the folio and its orientation. Never the folio's index, so inserting or moving a folio does not change it. Identical symbols stacked on one spot, or a copied folio, are told apart by a counter kept per project (QETProject::derivedUuid()), in load order among those symbols alone. A paste still renews uuids. Symbols that have a uuid in the file keep it. All 24 example projects already have one for every symbol, so they are unchanged; with the symbols' uuids stripped, each saves byte-for-byte the same twice (master: different every time). tst_derivedsymboluuid runs --resave on a fixture with its uuids stripped: same uuids on every load, same after a folio is inserted in front, saved uuids kept, stacked copies differ. The first two fail without this change. Co-Authored-By: Claude Opus 5.5 --- sources/diagram.cpp | 17 +++ sources/qetgraphicsitem/element.h | 1 + sources/qetproject.cpp | 21 +++ sources/qetproject.h | 2 + tests/qttest/CMakeLists.txt | 12 ++ tests/qttest/tst_derivedsymboluuid.cpp | 184 +++++++++++++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 tests/qttest/tst_derivedsymboluuid.cpp diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 4831b6484..bc0cd7c9e 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1676,6 +1676,23 @@ bool Diagram::fromXml(QDomElement &document, delete nvel_elmt; qDebug() << QStringLiteral("Diagram::fromXml() : Le chargement des parametres d'un element a echoue"); } else { + //A symbol saved without a uuid got a random one from + //Element::fromXml(): a different identity on every load, + //written out on the next save. Derive it instead from what + //the symbol is and where it sits on its folio -- never from + //the folio's index, so inserting or moving a folio does not + //change it. Only for a folio being loaded: a paste renews + //uuids anyway. + if (consider_informations && m_project + && QUuid(element_xml.attribute(QStringLiteral("uuid"))).isNull()) { + nvel_elmt->setUuid(m_project->derivedUuid( + QStringLiteral("element"), + QStringList{type_id, + element_xml.attribute(QStringLiteral("x")), + element_xml.attribute(QStringLiteral("y")), + element_xml.attribute(QStringLiteral("orientation"))} + .join(QLatin1Char('\n')))); + } ItemGroups::setGroup(nvel_elmt, ItemGroups::read(element_xml)); added_elements << nvel_elmt; } diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index 9bb02202d..33d8b752e 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -245,6 +245,7 @@ class Element : public QetGraphicsItem QString linkTypeToString() const; void newUuid() {m_uuid = QUuid::createUuid();} //create new uuid for this element + void setUuid(const QUuid &uuid) {m_uuid = uuid;} protected: void drawAxes(QPainter *, const QStyleOptionGraphicsItem *); diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index df45c2c08..e66714577 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -275,6 +275,27 @@ QUuid QETProject::uuid() const return m_uuid; } +/** + @brief QETProject::derivedUuid + A uuid for an item of this project that was saved without one, the same + on every load of the same file. + @p key describes the item by what it is, never by its place in the file + or its folio's index: inserting or moving a folio must not change it. + Items with the same @p kind and @p key anywhere in the project (a copied + folio, two identical symbols stacked on one spot) are told apart by a + counter, in load order among those items alone. + @return a UUID v5, which cannot collide with the v4 uuids given to new + items +*/ +QUuid QETProject::derivedUuid(const QString &kind, const QString &key) +{ + static const QUuid derived_ns(QStringLiteral("{7d1e9c3a-5b2f-4e8a-9c61-2f4b8d0e6a17}")); + const QString full = kind + QLatin1Char('\n') + key; + const int n = m_derived_uuid_keys[full]++; + return QUuid::createUuidV5(derived_ns, + n ? full + QLatin1Char('\n') + QString::number(n) : full); +} + /** @brief QETProject::init */ diff --git a/sources/qetproject.h b/sources/qetproject.h index e05f3b216..991b9f1a9 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -107,6 +107,7 @@ class QETProject : public QObject ProjectPropertiesHandler& projectPropertiesHandler(); projectDataBase *dataBase(); QUuid uuid() const; + QUuid derivedUuid(const QString &kind, const QString &key); ProjectState state() const; QList diagrams() const; int folioIndex(const Diagram *) const; @@ -365,6 +366,7 @@ class QETProject : public QObject QFuture m_backup_future; KAutoSaveFile m_backup_file; QUuid m_uuid = QUuid::createUuid(); + QHash m_derived_uuid_keys; projectDataBase m_data_base; QVector m_terminal_strip_vector; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 94283cc09..52d156547 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -328,3 +328,15 @@ 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=\"$\"") + +# A symbol saved without a uuid gets the same one on every load, and +# inserting a folio does not change it. Runs the real binary's --resave on +# fixtures/qet_bug_repro_resaved.qet with the symbols' uuids stripped. +add_executable( + tst_derivedsymboluuid + tst_derivedsymboluuid.cpp) +add_test(NAME tst_derivedsymboluuid COMMAND tst_derivedsymboluuid) +add_dependencies(tst_derivedsymboluuid qelectrotech) +target_link_libraries(tst_derivedsymboluuid PRIVATE Qt::Test Qt::Xml) +target_compile_definitions(tst_derivedsymboluuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") diff --git a/tests/qttest/tst_derivedsymboluuid.cpp b/tests/qttest/tst_derivedsymboluuid.cpp new file mode 100644 index 000000000..d3fa5861a --- /dev/null +++ b/tests/qttest/tst_derivedsymboluuid.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// A symbol saved without a uuid must get the same one on every load of the +// same file, and inserting a folio in front of it must not change it. Until +// this was fixed it got a random uuid each time (Element::fromXml), which +// the next save wrote out. +// +// Runs the real binary (--resave) on the fixture with its symbols' uuids +// stripped, and reads the uuids back from the saved file. +namespace { + +QDomDocument load(const QString &path) +{ + QDomDocument doc; + QFile file(path); + if (file.open(QIODevice::ReadOnly)) + doc.setContent(&file); + return doc; +} + +QList symbols(const QDomElement &diagram) +{ + QList out; + const QDomNodeList nodes = diagram.firstChildElement(QStringLiteral("elements")) + .elementsByTagName(QStringLiteral("element")); + for (int i = 0; i < nodes.size(); ++i) { + const QDomElement e = nodes.at(i).toElement(); + if (e.parentNode().parentNode() == diagram) + out << e; + } + return out; +} + +QList diagrams(const QDomDocument &doc) +{ + QList out; + for (QDomElement d = doc.documentElement().firstChildElement(QStringLiteral("diagram")); + !d.isNull(); d = d.nextSiblingElement(QStringLiteral("diagram"))) + out << d; + return out; +} + +// Symbols without uuids, and no wires (they refer to symbols by uuid). +void stripUuids(QDomDocument &doc) +{ + for (QDomElement d : diagrams(doc)) { + for (QDomElement e : symbols(d)) + e.removeAttribute(QStringLiteral("uuid")); + d.removeChild(d.firstChildElement(QStringLiteral("conductors"))); + } +} + +// type|x|y|orientation -> uuid, for every symbol in the file +QMultiHash symbolUuids(const QDomDocument &doc) +{ + QMultiHash out; + for (const QDomElement &d : diagrams(doc)) + for (const QDomElement &e : symbols(d)) + out.insert(QStringList{e.attribute(QStringLiteral("type")), + e.attribute(QStringLiteral("x")), + e.attribute(QStringLiteral("y")), + e.attribute(QStringLiteral("orientation"))} + .join(QLatin1Char('|')), + e.attribute(QStringLiteral("uuid"))); + return out; +} + +} // namespace + +class tst_derivedsymboluuid : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + // Save @p doc, run --resave on it in a sandbox of its own (so a running + // QElectroTech cannot answer instead), and return what was saved. + QDomDocument resave(const QDomDocument &doc) + { + 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(doc.toByteArray()); + 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(60000) || proc.exitCode() != 0) return {}; + return load(out); + } + + QDomDocument fixture() + { + QDomDocument doc = load(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet")); + stripUuids(doc); + return doc; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + QVERIFY(!fixture().isNull()); + } + + void sameUuidsOnEveryLoad() + { + const QMultiHash a = symbolUuids(resave(fixture())); + const QMultiHash b = symbolUuids(resave(fixture())); + QCOMPARE(a.size(), 9); // the fixture's placed symbols + QCOMPARE(a, b); + for (const QString &u : a) + QVERIFY2(!QUuid(u).isNull(), qPrintable(u)); + QCOMPARE(QSet(a.begin(), a.end()).size(), a.size()); + } + + void insertingAFolioChangesNothing() + { + QDomDocument moved = fixture(); + QDomElement first = diagrams(moved).first(); + QDomElement blank = first.cloneNode(false).toElement(); + blank.setAttribute(QStringLiteral("title"), QStringLiteral("new")); + blank.appendChild(moved.createElement(QStringLiteral("elements"))); + moved.documentElement().insertBefore(blank, first); + + const QMultiHash after = symbolUuids(resave(moved)); + QCOMPARE(after.size(), 9); + QCOMPARE(after, symbolUuids(resave(fixture()))); + } + + void savedUuidsAreKept() + { + QDomDocument doc = load(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet")); + QCOMPARE(symbolUuids(resave(doc)), symbolUuids(doc)); + } + + void stackedIdenticalSymbolsDiffer() + { + QDomDocument doc = fixture(); + QDomElement one = symbols(diagrams(doc).first()).first(); + QDomElement copy = one.cloneNode(true).toElement(); + //A copy's terminals carry their own file ids; a symbol whose + //terminal ids are already taken is not loaded at all. + const QDomNodeList terminals = copy.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("id"), 90000 + i); + one.parentNode().appendChild(copy); + + const QStringList both = symbolUuids(resave(doc)).values( + QStringList{one.attribute(QStringLiteral("type")), + one.attribute(QStringLiteral("x")), + one.attribute(QStringLiteral("y")), + one.attribute(QStringLiteral("orientation"))} + .join(QLatin1Char('|'))); + QCOMPARE(both.size(), 2); + QVERIFY(both.at(0) != both.at(1)); + } +}; + +QTEST_APPLESS_MAIN(tst_derivedsymboluuid) + +#include "tst_derivedsymboluuid.moc" From e12410e439b42c914a0ce14fdb1a87596dbf72c1 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 20:46:01 +1300 Subject: [PATCH 04/13] Never derive a uuid the file already carries A symbol keeps its derived uuid once saved, even when it is moved. A symbol saved without a uuid that later turns up on the spot it left -- a hand edit, an older version, another tool writing the file -- derived the same uuid, and the project had two symbols with one identity. readDiagramsXml() now collects every symbol and wire uuid the file carries, on any folio, before a folio loads; derivedItemUuid() moves to the next counter value while a candidate is among them. The result still depends on the file alone. Renamed from derivedUuid(), which QETProject already has for the project's own uuid. tst_derivedsymboluuid: newcomerOnAMovedSymbolsSpotGetsAnotherUuid fails with the check switched off. Co-Authored-By: Claude Opus 5.5 --- sources/diagram.cpp | 2 +- sources/qetproject.cpp | 37 ++++++++++++++++++++++---- sources/qetproject.h | 4 ++- tests/qttest/tst_derivedsymboluuid.cpp | 22 +++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/sources/diagram.cpp b/sources/diagram.cpp index bc0cd7c9e..ee3b6540b 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1685,7 +1685,7 @@ bool Diagram::fromXml(QDomElement &document, //uuids anyway. if (consider_informations && m_project && QUuid(element_xml.attribute(QStringLiteral("uuid"))).isNull()) { - nvel_elmt->setUuid(m_project->derivedUuid( + nvel_elmt->setUuid(m_project->derivedItemUuid( QStringLiteral("element"), QStringList{type_id, element_xml.attribute(QStringLiteral("x")), diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index e66714577..b89acc626 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -276,7 +276,7 @@ QUuid QETProject::uuid() const } /** - @brief QETProject::derivedUuid + @brief QETProject::derivedItemUuid A uuid for an item of this project that was saved without one, the same on every load of the same file. @p key describes the item by what it is, never by its place in the file @@ -284,16 +284,31 @@ QUuid QETProject::uuid() const Items with the same @p kind and @p key anywhere in the project (a copied folio, two identical symbols stacked on one spot) are told apart by a counter, in load order among those items alone. + + A derived uuid is never one the file already carries: an item saved with + a derived uuid and then moved or re-connected keeps it, so a newcomer + later taking its old place or ends would otherwise derive the same one. + The file's saved uuids are collected before any folio loads + (readDiagramsXml()), so the result still depends on the file alone. + + uuids are unique within one project; copies of a project share them, as + they share every saved uuid. Anything bringing items in from another + project must renew them, as paste does. @return a UUID v5, which cannot collide with the v4 uuids given to new items */ -QUuid QETProject::derivedUuid(const QString &kind, const QString &key) +QUuid QETProject::derivedItemUuid(const QString &kind, const QString &key) { static const QUuid derived_ns(QStringLiteral("{7d1e9c3a-5b2f-4e8a-9c61-2f4b8d0e6a17}")); const QString full = kind + QLatin1Char('\n') + key; - const int n = m_derived_uuid_keys[full]++; - return QUuid::createUuidV5(derived_ns, - n ? full + QLatin1Char('\n') + QString::number(n) : full); + int &n = m_derived_uuid_keys[full]; + QUuid uuid; + do { + uuid = QUuid::createUuidV5(derived_ns, + n ? full + QLatin1Char('\n') + QString::number(n) : full); + ++n; + } while (m_saved_item_uuids.contains(uuid)); + return uuid; } /** @@ -1909,6 +1924,18 @@ void QETProject::readDiagramsXml(QDomDocument &xml_project) //Search the diagrams in the project QDomNodeList diagram_nodes = xml_project.elementsByTagName(QStringLiteral("diagram")); + //Every symbol and wire uuid the file already carries, on any folio, + //before a folio derives one for an item saved without: see + //derivedItemUuid(). + for (const QString &tag : {QStringLiteral("element"), QStringLiteral("conductor")}) { + const QDomNodeList nodes = xml_project.elementsByTagName(tag); + for (int i = 0; i < nodes.size(); ++i) { + const QUuid saved(nodes.at(i).toElement().attribute(QStringLiteral("uuid"))); + if (!saved.isNull()) + m_saved_item_uuids.insert(saved); + } + } + if(dlgWaiting) dlgWaiting->setProgressBarRange(0, diagram_nodes.length()*3); diff --git a/sources/qetproject.h b/sources/qetproject.h index 991b9f1a9..35830912c 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -36,6 +36,7 @@ #endif #include +#include #include class Diagram; @@ -107,7 +108,7 @@ class QETProject : public QObject ProjectPropertiesHandler& projectPropertiesHandler(); projectDataBase *dataBase(); QUuid uuid() const; - QUuid derivedUuid(const QString &kind, const QString &key); + QUuid derivedItemUuid(const QString &kind, const QString &key); ProjectState state() const; QList diagrams() const; int folioIndex(const Diagram *) const; @@ -367,6 +368,7 @@ class QETProject : public QObject KAutoSaveFile m_backup_file; QUuid m_uuid = QUuid::createUuid(); QHash m_derived_uuid_keys; + QSet m_saved_item_uuids; //symbol and wire uuids the file carries, see derivedItemUuid() projectDataBase m_data_base; QVector m_terminal_strip_vector; diff --git a/tests/qttest/tst_derivedsymboluuid.cpp b/tests/qttest/tst_derivedsymboluuid.cpp index d3fa5861a..e5a0e67f8 100644 --- a/tests/qttest/tst_derivedsymboluuid.cpp +++ b/tests/qttest/tst_derivedsymboluuid.cpp @@ -156,6 +156,28 @@ private slots: QCOMPARE(symbolUuids(resave(doc)), symbolUuids(doc)); } + // A symbol keeps its derived uuid once saved, even when moved. A symbol + // saved without a uuid that later turns up on the spot it left (a hand + // edit, an older version, another tool) would derive the same uuid: it + // must get another one instead. + void newcomerOnAMovedSymbolsSpotGetsAnotherUuid() + { + QDomDocument doc = resave(fixture()); + QDomElement moved = symbols(diagrams(doc).first()).first(); + QVERIFY(!QUuid(moved.attribute(QStringLiteral("uuid"))).isNull()); + QDomElement newcomer = moved.cloneNode(true).toElement(); + newcomer.removeAttribute(QStringLiteral("uuid")); + const QDomNodeList terminals = newcomer.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("id"), 90000 + i); + moved.setAttribute(QStringLiteral("x"), moved.attribute(QStringLiteral("x")).toInt() + 500); + moved.parentNode().appendChild(newcomer); + + const QMultiHash after = symbolUuids(resave(doc)); + QCOMPARE(after.size(), 10); + QCOMPARE(QSet(after.begin(), after.end()).size(), 10); + } + void stackedIdenticalSymbolsDiffer() { QDomDocument doc = fixture(); From b3a4e30010b26446ec71eb29aa22b920501cb207 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 20:57:02 +1300 Subject: [PATCH 05/13] Give wires saved without a uuid a lasting one, from what they connect A wire saved without a uuid got a random one on every load, never saved (#754): it had no identity from one session to the next, so a script could only name it as "the wire on terminal N of symbol X", and a comparison of two versions could not tell a moved wire from a new one. When a folio is loaded, such a wire now gets a UUID v5 derived from its two ends -- the symbol and terminal at each, sorted so the direction it was drawn in does not matter -- and it is written on save. Never its place in the file or its folio's index: inserting or moving a folio, or saving the wires in another order, does not change it. Once saved the uuid no longer depends on the ends, so re-connecting the wire keeps it; QETProject::derivedItemUuid() never hands out a uuid the file already carries, so a wire later drawn on the ends it left gets another one. Wires that have a uuid keep it; a paste still renews them. The 24 example projects: 3,189 wires, none with a uuid before, all 3,189 after one save, none lost, no uuid used twice in any project; two saves of the same file are identical, and a second save keeps every wire's uuid. Discussion #1103 has the measurements behind the recipe. tst_derivedwireuuid runs --resave on a fixture naming ends by uuid and on examples/tremie_vibrante.qet (ends by terminal number): every wire gets a distinct uuid, the same on every load, read back after a save, kept per wire when a folio is inserted, the wires are reordered or a wire is drawn the other way, and a newcomer on a re-connected wire's old ends gets another uuid. Without this change 14 of the 18 fail; with the uuid taken from folio index and file order instead, the folio-insert and reorder tests fail. Co-Authored-By: Claude Opus 5.5 --- sources/diagram.cpp | 22 ++ sources/qetgraphicsitem/conductor.h | 1 + tests/qttest/CMakeLists.txt | 14 ++ tests/qttest/tst_derivedwireuuid.cpp | 307 +++++++++++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 tests/qttest/tst_derivedwireuuid.cpp diff --git a/sources/diagram.cpp b/sources/diagram.cpp index ee3b6540b..237d0a5a8 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1825,6 +1825,28 @@ bool Diagram::fromXml(QDomElement &document, { addItem(c); c -> fromXml(f); + //A wire saved without a uuid got a random one that was + //never saved (#754), so it had no identity from one + //session to the next. Derive it from what it connects: + //the symbol and terminal at each end, sorted so the + //direction it was drawn in does not matter. Never its + //place in the file or its folio's index, so inserting or + //moving a folio, or saving the wires in another order, + //does not change it. It is saved from now on, so + //re-connecting the wire later keeps it; derivedItemUuid() + //never hands out a uuid the file already carries, so a + //wire later drawn on the ends it left gets another one. + if (consider_informations && m_project + && QUuid(f.attribute(QStringLiteral("uuid"))).isNull()) { + auto end = [](const Terminal *t) { + return t->parentElement()->uuid().toString() + + QLatin1Char('/') + t->stableUuid().toString(); + }; + QStringList ends{end(p1), end(p2)}; + ends.sort(); + c->setUuid(m_project->derivedItemUuid(QStringLiteral("conductor"), + ends.join(QLatin1Char('\n')))); + } added_conductors << c; } else diff --git a/sources/qetgraphicsitem/conductor.h b/sources/qetgraphicsitem/conductor.h index d1ecd6e43..c2f3232e5 100644 --- a/sources/qetgraphicsitem/conductor.h +++ b/sources/qetgraphicsitem/conductor.h @@ -80,6 +80,7 @@ class Conductor : public QGraphicsObject ConductorTextItem *textItem() const; QUuid uuid() const {return m_uuid;} void newUuid() {m_uuid = QUuid::createUuid(); m_persist_uuid = true;} //create new uuid for this conductor + void setUuid(const QUuid &uuid) {m_uuid = uuid; m_persist_uuid = true;} //saved from now on void updatePath(const QRectF & = QRectF()); //This method do nothing, it's only made to be used with Q_PROPERTY diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 52d156547..678fe151c 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -340,3 +340,17 @@ add_dependencies(tst_derivedsymboluuid qelectrotech) target_link_libraries(tst_derivedsymboluuid PRIVATE Qt::Test Qt::Xml) target_compile_definitions(tst_derivedsymboluuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + +# A wire saved without a uuid gets one derived from its two ends: the same +# on every load, saved, and unchanged by inserting a folio, reordering the +# wires or drawing a wire the other way. Runs the real binary's --resave on +# a fixture and on examples/tremie_vibrante.qet (ends by terminal number). +add_executable( + tst_derivedwireuuid + tst_derivedwireuuid.cpp) +add_test(NAME tst_derivedwireuuid COMMAND tst_derivedwireuuid) +add_dependencies(tst_derivedwireuuid qelectrotech) +target_link_libraries(tst_derivedwireuuid PRIVATE Qt::Test Qt::Xml) +target_compile_definitions(tst_derivedwireuuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") diff --git a/tests/qttest/tst_derivedwireuuid.cpp b/tests/qttest/tst_derivedwireuuid.cpp new file mode 100644 index 000000000..7bfc78b04 --- /dev/null +++ b/tests/qttest/tst_derivedwireuuid.cpp @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A wire saved without a uuid must get one that is the same on every load, +// is written on save, and survives the edits people make before that save: +// inserting a folio, saving the wires in another order, drawing the wire +// the other way round. Until this was fixed it got a random uuid on every +// load, never saved (#754). +// +// Runs the real binary (--resave) on two fixtures -- one whose wires name +// their ends by symbol and terminal uuid, one older file naming them by +// terminal number -- and reads the wires' uuids back from the saved file. +namespace { + +QDomDocument load(const QString &path) +{ + QDomDocument doc; + QFile file(path); + if (file.open(QIODevice::ReadOnly)) + doc.setContent(&file); + return doc; +} + +QList diagrams(const QDomDocument &doc) +{ + QList out; + for (QDomElement d = doc.documentElement().firstChildElement(QStringLiteral("diagram")); + !d.isNull(); d = d.nextSiblingElement(QStringLiteral("diagram"))) + out << d; + return out; +} + +QList wires(const QDomElement &diagram) +{ + QList out; + const QDomElement block = diagram.firstChildElement(QStringLiteral("conductors")); + for (QDomElement c = block.firstChildElement(QStringLiteral("conductor")); + !c.isNull(); c = c.nextSiblingElement(QStringLiteral("conductor"))) + out << c; + return out; +} + +// Every wire's uuid in the file, sorted: equal lists mean every wire kept +// its uuid. +QStringList wireUuids(const QDomDocument &doc) +{ + QStringList out; + for (const QDomElement &d : diagrams(doc)) + for (const QDomElement &c : wires(d)) + out << c.attribute(QStringLiteral("uuid")); + out.sort(); + return out; +} + +// Which wire carries which uuid: each wire keyed by its two ends as the +// saved file names them, sorted. An older file names an end by terminal +// number, resolved here to the symbol and the terminal's place on it. +QMap wireMap(const QDomDocument &doc) +{ + QMap out; + for (const QDomElement &d : diagrams(doc)) { + QHash by_number; + const QDomNodeList symbols = d.firstChildElement(QStringLiteral("elements")) + .elementsByTagName(QStringLiteral("element")); + for (int i = 0; i < symbols.size(); ++i) { + const QDomElement e = symbols.at(i).toElement(); + const QDomNodeList ts = e.elementsByTagName(QStringLiteral("terminal")); + for (int j = 0; j < ts.size(); ++j) { + const QDomElement t = ts.at(j).toElement(); + by_number.insert(t.attribute(QStringLiteral("id")), + e.attribute(QStringLiteral("uuid")) + QLatin1Char('/') + + t.attribute(QStringLiteral("x")) + QLatin1Char(',') + + t.attribute(QStringLiteral("y"))); + } + } + for (const QDomElement &c : wires(d)) { + QStringList ends; + for (const QString n : {QStringLiteral("1"), QStringLiteral("2")}) { + const QString element = c.attribute(QStringLiteral("element") + n); + const QString terminal = c.attribute(QStringLiteral("terminal") + n); + ends << (element.isEmpty() ? by_number.value(terminal, QStringLiteral("?")) + : element + QLatin1Char('/') + terminal); + } + ends.sort(); + out.insert(ends.join(QLatin1Char('|')), c.attribute(QStringLiteral("uuid"))); + } + } + return out; +} + +} // namespace + +class tst_derivedwireuuid : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + // Save @p doc, run --resave on it in a sandbox of its own (so a running + // QElectroTech cannot answer instead), and return what was saved. + QDomDocument resave(const QDomDocument &doc) + { + 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(doc.toByteArray()); + 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 {}; + return load(out); + } + + // The fixture as an older QElectroTech saved it: wires without uuids. + QDomDocument fixture() + { + QFETCH(QString, path); + QDomDocument doc = load(path); + for (const QDomElement &d : diagrams(doc)) + for (QDomElement c : wires(d)) + c.removeAttribute(QStringLiteral("uuid")); + return doc; + } + + // The saved uuids of the unedited fixture, checked for sanity. + QStringList reference() + { + QFETCH(int, count); + const QStringList ids = wireUuids(resave(fixture())); + if (ids.size() != count) return {}; + for (const QString &u : ids) + if (QUuid(u).isNull()) return {}; + return ids; + } + + void fixtures() + { + QTest::addColumn("path"); + QTest::addColumn("count"); + QTest::newRow("ends by uuid") + << QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet") << 7; + QTest::newRow("ends by terminal number (older file)") + << QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet") << 77; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void savedAndUnique_data() { fixtures(); } + void savedAndUnique() + { + QFETCH(int, count); + const QStringList ids = reference(); + QCOMPARE(ids.size(), count); // every wire has one + QCOMPARE(QSet(ids.begin(), ids.end()).size(), count); // all different + QCOMPARE(wireMap(resave(fixture())).size(), count); // the test's wire key is unique too + } + + void sameOnEveryLoad_data() { fixtures(); } + void sameOnEveryLoad() + { + const QStringList a = reference(); + QVERIFY(!a.isEmpty()); + QCOMPARE(wireUuids(resave(fixture())), a); + } + + void savedUuidIsReadBack_data() { fixtures(); } + void savedUuidIsReadBack() + { + const QDomDocument once = resave(fixture()); + QVERIFY(!wireUuids(once).isEmpty()); + QCOMPARE(wireUuids(resave(once)), wireUuids(once)); + } + + void insertingAFolioChangesNothing_data() { fixtures(); } + void insertingAFolioChangesNothing() + { + QVERIFY(!reference().isEmpty()); + const QMap before = wireMap(resave(fixture())); + QDomDocument doc = fixture(); + const QDomElement first = diagrams(doc).first(); + QDomElement blank = first.cloneNode(false).toElement(); + blank.setAttribute(QStringLiteral("title"), QStringLiteral("new")); + blank.removeAttribute(QStringLiteral("uuid")); + blank.appendChild(doc.createElement(QStringLiteral("elements"))); + doc.documentElement().insertBefore(blank, first); + QCOMPARE(wireMap(resave(doc)), before); + } + + void wireOrderDoesNotMatter_data() { fixtures(); } + void wireOrderDoesNotMatter() + { + QVERIFY(!reference().isEmpty()); + const QMap before = wireMap(resave(fixture())); + QDomDocument doc = fixture(); + for (const QDomElement &d : diagrams(doc)) { + QDomElement block = d.firstChildElement(QStringLiteral("conductors")); + const QList ws = wires(d); + for (const QDomElement &w : ws) + block.removeChild(w); + for (auto it = ws.crbegin(); it != ws.crend(); ++it) // reversed + block.appendChild(*it); + } + QCOMPARE(wireMap(resave(doc)), before); + } + + void directionDoesNotMatter_data() { fixtures(); } + void directionDoesNotMatter() + { + QVERIFY(!reference().isEmpty()); + const QMap before = wireMap(resave(fixture())); + QDomDocument doc = fixture(); + static const QRegularExpression pair( + QStringLiteral("^(element|terminal|terminalname)([12])(.*)$")); + for (const QDomElement &d : diagrams(doc)) { + for (QDomElement w : wires(d)) { + const QDomNamedNodeMap attrs = w.attributes(); + QHash swapped; + for (int i = 0; i < attrs.size(); ++i) { + const QString name = attrs.item(i).nodeName(); + const auto m = pair.match(name); + if (m.hasMatch()) + swapped.insert(m.captured(1) + (m.captured(2) == QLatin1String("1") + ? QStringLiteral("2") : QStringLiteral("1")) + + m.captured(3), + attrs.item(i).nodeValue()); + } + for (auto it = swapped.cbegin(); it != swapped.cend(); ++it) + w.setAttribute(it.key(), it.value()); + } + } + QCOMPARE(wireMap(resave(doc)), before); + } + + // A wire keeps its uuid once saved, even when re-connected. A wire saved + // without a uuid that later turns up on the ends it left (a hand edit, an + // older version, another tool) would derive the same uuid: it must get + // another one instead. + void newcomerOnAReconnectedWiresEndsGetsAnotherUuid_data() { fixtures(); } + void newcomerOnAReconnectedWiresEndsGetsAnotherUuid() + { + QFETCH(int, count); + QDomDocument doc = resave(fixture()); + QVERIFY(!wireUuids(doc).isEmpty()); + const QList ws = wires(diagrams(doc).first()); + QVERIFY(ws.size() >= 2); + QDomElement x = ws.at(0); + const QDomElement y = ws.at(1); + QDomElement newcomer = x.cloneNode(true).toElement(); // x's old ends + newcomer.removeAttribute(QStringLiteral("uuid")); + // re-connect x's second end to y's second end, keeping x's uuid + static const QRegularExpression end2(QStringLiteral("^(element|terminal|terminalname)2")); + const QDomNamedNodeMap attrs = y.attributes(); + for (int i = 0; i < attrs.size(); ++i) + if (end2.match(attrs.item(i).nodeName()).hasMatch()) + x.setAttribute(attrs.item(i).nodeName(), attrs.item(i).nodeValue()); + x.parentNode().appendChild(newcomer); + + const QStringList after = wireUuids(resave(doc)); + QCOMPARE(after.size(), count + 1); + QCOMPARE(QSet(after.begin(), after.end()).size(), count + 1); + } + + // QElectroTech refuses a second wire between two terminals already joined + // (Terminal::canBeLinkedTo()), so two wires on one folio never share both + // ends. A file that has one anyway must still load to the same uuids. + void doubledWireIsDroppedAndOthersKeepTheirs_data() { fixtures(); } + void doubledWireIsDroppedAndOthersKeepTheirs() + { + QVERIFY(!reference().isEmpty()); + const QMap before = wireMap(resave(fixture())); + QDomDocument doc = fixture(); + QDomElement w = wires(diagrams(doc).first()).first(); + w.parentNode().appendChild(w.cloneNode(true)); + QCOMPARE(wireMap(resave(doc)), before); + } +}; + +QTEST_APPLESS_MAIN(tst_derivedwireuuid) + +#include "tst_derivedwireuuid.moc" From 75450d7102f8d45696327e290391f4e92ffcbba5 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 21:59:02 +1300 Subject: [PATCH 06/13] Make saving a just-saved project change nothing Saving a project that had just been saved changed it again in 18 of the 24 example projects, so a project kept in version control showed changes nobody made. Both causes were cleanup done on save but not on load: - Symbol information whose values were all empty was written as an empty block (DiagramContext::toXml() skips empty values, Element::toXml() wrote the block anyway). The next load read it as no information and the next save dropped it. The block is now written only when something went into it. - Information values were trimmed on save but not on load, so a label with stray spaces (" PRISE") kept them in memory and in its displayed copy until the project was opened again. The same rule, kept in one place, now applies when reading: stray whitespace around real content trimmed, a value that is only whitespace kept (#973). All 24 examples now save identically a second time (master: 6), and each one's first save is byte-for-byte what master wrote only on its second. A title-block property set to a single space keeps it through two saves. tst_resaveunchanged runs --resave twice on Projet_vierge.qet and m_000.qet; both fail without this change. Co-Authored-By: Claude Opus 5.5 --- sources/diagramcontext.cpp | 36 +++++++++---- sources/qetgraphicsitem/element.cpp | 6 ++- tests/qttest/CMakeLists.txt | 13 +++++ tests/qttest/tst_resaveunchanged.cpp | 81 ++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 tests/qttest/tst_resaveunchanged.cpp diff --git a/sources/diagramcontext.cpp b/sources/diagramcontext.cpp index 5137a7bf5..94b8696a2 100644 --- a/sources/diagramcontext.cpp +++ b/sources/diagramcontext.cpp @@ -144,6 +144,27 @@ bool DiagramContext::operator!=(const DiagramContext &dc) const return(!(*this == dc)); } +namespace { +/** + The value as it is saved, and so as it is read back: stray leading and + trailing whitespace around real content trimmed, but not a value that IS + whitespace -- unconditionally trimming an all-whitespace string collapses + it to "", which is silently indistinguishable from a value that was never + set. A title-block custom variable set to a single space -- a workaround + for #973, where an unset variable renders as its own literal placeholder + -- would otherwise vanish on the very next save. + Applied when reading as well as when writing, so that what is in memory + after a load is what the next save writes: a label shown from an + untrimmed value would otherwise keep its spaces on screen and in its + displayed copy until the project was saved and opened again, and a + just-saved project would change on its second save. +*/ +QString storedValue(const QString &raw) +{ + return raw.trimmed().isEmpty() ? raw : raw.trimmed(); +} +} // namespace + /** Export this context properties under the \a e XML element, using tags named \a tag_name (defaults to "property"). @@ -161,15 +182,7 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const property.removeAttribute("name"); property.setAttribute("show", m_content_show[key]); property.setAttribute("name", key); - // Trim stray leading/trailing whitespace around real content, but - // not a value that IS whitespace: unconditionally trimming an - // all-whitespace string collapses it to "", which is silently - // indistinguishable from a value that was never set. A title-block - // custom variable set to a single space -- a workaround for #973, - // where an unset variable renders as its own literal placeholder -- - // would otherwise vanish on the very next save. - const QString stored = raw.trimmed().isEmpty() ? raw : raw.trimmed(); - QDomText value = e.ownerDocument().createTextNode(stored); + QDomText value = e.ownerDocument().createTextNode(storedValue(raw)); property.appendChild(value); e.appendChild(property); } @@ -182,7 +195,7 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const void DiagramContext::fromXml(const QDomElement &e, const QString &tag_name) { foreach (QDomElement property, QET::findInDomElement(e, tag_name)) { if (!property.hasAttribute("name")) continue; - addValue(property.attribute("name"), QVariant(property.text())); + addValue(property.attribute("name"), QVariant(storedValue(property.text()))); m_content_show.insert(property.attribute("name"), property.attribute("show", "1").toInt()); } } @@ -198,7 +211,8 @@ void DiagramContext::fromXml(const pugi::xml_node &dom_element, const QString &t { for(auto node = dom_element.child(tag_name.toStdString().c_str()) ; node ; node = node.next_sibling(tag_name.toStdString().c_str())) { - addValue(node.attribute("name").as_string(), QVariant(node.text().as_string())); + addValue(node.attribute("name").as_string(), + QVariant(storedValue(QString::fromUtf8(node.text().as_string())))); m_content_show.insert(node.attribute("name").as_string(), node.attribute("show").empty()? 1 : node.attribute("show").as_int()); } } diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index fbbc3df5d..eb9dbfbe2 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -1033,7 +1033,11 @@ QDomElement Element::toXml( QDomElement infos = document.createElement(QStringLiteral("elementInformations")); m_data.m_informations.toXml(infos, QStringLiteral("elementInformation")); - element.appendChild(infos); + //toXml() skips empty values: an element whose information is + //all empty would otherwise be written an empty block, which the + //next load reads as no information and the next save drops. + if (infos.hasChildNodes()) + element.appendChild(infos); } //Save override properties (For now, only used when the element is a terminal) diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 94283cc09..525ccb8cd 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -328,3 +328,16 @@ 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=\"$\"") + +# Saving a project that was just saved changes nothing: runs the real +# binary's --resave twice on examples/Projet_vierge.qet (empty information +# values) and examples/m_000.qet (information values with stray spaces). +add_executable( + tst_resaveunchanged + tst_resaveunchanged.cpp) +add_test(NAME tst_resaveunchanged COMMAND tst_resaveunchanged) +add_dependencies(tst_resaveunchanged qelectrotech) +target_link_libraries(tst_resaveunchanged PRIVATE Qt::Test) +target_compile_definitions(tst_resaveunchanged PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp new file mode 100644 index 000000000..aff0fe360 --- /dev/null +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include + +// Saving a project that was just saved must change nothing. Two things +// made the second save differ from the first, both cleanup done on save +// but not on load: +// - symbol information whose values were all empty was written as an +// empty block, which the next load read as no +// information and the next save dropped (Projet_vierge.qet); +// - information values were trimmed on save but not on load, so a label +// with stray spaces kept them in its displayed copy until the project +// was opened again (m_000.qet). +// Runs the real binary's --resave twice on each example. +class tst_resaveunchanged : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + // --resave @p in to a new file, in a sandbox of its own (so a running + // QElectroTech cannot answer instead); returns the new file's path. + QString resave(const QString &in) + { + 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); + 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 {}; + return out; + } + + static QByteArray read(const QString &path) + { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void secondSaveChangesNothing_data() + { + QTest::addColumn("project"); + QTest::newRow("empty information values") << QStringLiteral("Projet_vierge.qet"); + QTest::newRow("information values with stray spaces") << QStringLiteral("m_000.qet"); + } + + void secondSaveChangesNothing() + { + QFETCH(QString, project); + const QString first = resave(QStringLiteral(QET_EXAMPLES_DIR "/") + project); + QVERIFY2(!first.isEmpty(), "first --resave failed"); + const QString second = resave(first); + QVERIFY2(!second.isEmpty(), "second --resave failed"); + const QByteArray a = read(first), b = read(second); + QVERIFY(!a.isEmpty()); + QVERIFY2(a == b, "the second save changed the file"); + } +}; + +QTEST_APPLESS_MAIN(tst_resaveunchanged) + +#include "tst_resaveunchanged.moc" From 9db608438705538c28e81702255a84e2c3d479b8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 22:04:30 +1300 Subject: [PATCH 07/13] Build tst_scriptconductoruuid only where --run exists The test runs a script through --run, which a build without the Qt Qml module does not have (QET_HAS_SCRIPTING). Linux CI installs no Qml package, so the binary took the script for a project to open and the test waited out its 60 s. The test is now built only when scripting is: a configure with Qt6Qml disabled lists 24 tests, without it; with Qml it runs and passes as before. Co-Authored-By: Claude Opus 5.5 --- tests/qttest/CMakeLists.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 64a3b8cd0..9456b5bf4 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -331,12 +331,16 @@ target_compile_definitions(tst_conductorselfretrace PRIVATE # qet.conductorUuids() / qet.conductorEnds(): a script lists a folio's # conductors by uuid and finds each one's two ends. Runs a script through -# the real binary's --run on fixtures/qet_bug_repro_resaved.qet. -add_executable( - tst_scriptconductoruuid - tst_scriptconductoruuid.cpp) -add_test(NAME tst_scriptconductoruuid COMMAND tst_scriptconductoruuid) -add_dependencies(tst_scriptconductoruuid qelectrotech) -target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test) -target_compile_definitions(tst_scriptconductoruuid PRIVATE - "QET_TEST_BINARY_PATH=\"$\"") +# the real binary's --run on fixtures/qet_bug_repro_resaved.qet, so only +# where --run exists: a build without the Qt Qml module has no scripting +# (see QET_HAS_SCRIPTING in the top-level CMakeLists.txt). +if(QET_HAS_SCRIPTING) + add_executable( + tst_scriptconductoruuid + tst_scriptconductoruuid.cpp) + add_test(NAME tst_scriptconductoruuid COMMAND tst_scriptconductoruuid) + add_dependencies(tst_scriptconductoruuid qelectrotech) + target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test) + target_compile_definitions(tst_scriptconductoruuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") +endif() From 6ed41bbfd0bcd13e0c21589c5b85a02f72c10f0b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 22:33:28 +1300 Subject: [PATCH 08/13] Click again on a member of a selected group to pick it on its own Clicking an item of a group selects the whole group (#1070). Clicking again on one of its items is meant to select just that item, to edit it on its own -- what discussion #1070 proposed -- but the second click selected the whole group again: Qt left only the clicked item selected on release, and the group completion pulled the others back in. A press on a member of a group that is selected whole now notes that member (ItemGroups::memberToPick(), which also finds the member when the click lands on a symbol's own text). If the click ends without a drag and Qt has left only that member selected, the selection stays so. A drag still moves the whole group; Ctrl+click keeps its meaning; a group of one is not picked from. In the GUI, on two grouped texts: one click then Delete removes both (master and this); click, click again, Delete removes only the clicked text here, both on master; dragging after one click moves both texts by the same amount on both. tst_itemgroups: 5 new checks; removing the whole-group or the group-of-one condition fails one each. ctest 24/24. Co-Authored-By: Claude Opus 5.5 --- sources/diagram.cpp | 31 ++++++++++++++++++++++++ sources/diagram.h | 3 +++ sources/itemgroups.cpp | 41 ++++++++++++++++++++++++++++++++ sources/itemgroups.h | 3 +++ tests/qttest/tst_itemgroups.cpp | 42 +++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 4831b6484..498a82341 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -437,6 +437,24 @@ void Diagram::mousePressEvent(QGraphicsSceneMouseEvent *event) } rememberSelection(); + //Clicking again on a member of a group that is selected whole picks + //that member out, to edit it on its own (discussion #1070): noted + //here, decided on release, since a drag must still move the group. + //Ctrl keeps its usual meaning. + m_member_to_pick.clear(); + if (event->button() == Qt::LeftButton + && !event->modifiers().testFlag(Qt::ControlModifier)) { + QTransform view_transform; + if (event->widget()) { + if (auto view = qobject_cast(event->widget()->parentWidget())) { + view_transform = view->transform(); + } + } + if (QGraphicsItem *member = ItemGroups::memberToPick( + itemAt(event->scenePos(), view_transform))) { + m_member_to_pick = member->toGraphicsObject(); + } + } QGraphicsScene::mousePressEvent(event); completeGroupSelection(); } @@ -477,6 +495,19 @@ void Diagram::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } QGraphicsScene::mouseReleaseEvent(event); + + //A click that did not drag, on a member of a group selected whole: + //Qt has left only that member selected, and it stays so. + QGraphicsObject *picked = m_member_to_pick.data(); + m_member_to_pick.clear(); + if (picked + && (event->screenPos() - event->buttonDownScreenPos(Qt::LeftButton)).manhattanLength() + < QApplication::startDragDistance() + && selectedItems() == QList{picked}) { + rememberSelection(); + return; + } + //A click on an already selected item changes the selection on //release, not on press (Ctrl toggles it, a plain click keeps only it). completeGroupSelection(); diff --git a/sources/diagram.h b/sources/diagram.h index 8c2b453d6..83d408835 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -142,6 +142,9 @@ class Diagram : public QGraphicsScene //Selection before the current click, see completeGroupSelection() QList> m_previous_selection; void rememberSelection(); + //Member of a wholly selected group under the current click, which + //the click picks out on its own if it ends without a drag + QPointer m_member_to_pick; bool uuidUsedByOtherDiagram(const QUuid &uuid) const; QUuid derivedUuid(const QDomElement &root, const QString &reason) const; diff --git a/sources/itemgroups.cpp b/sources/itemgroups.cpp index 4e4c7ac9d..60da47593 100644 --- a/sources/itemgroups.cpp +++ b/sources/itemgroups.cpp @@ -62,6 +62,47 @@ QUuid ItemGroups::read(const QDomElement &xml) return QUuid(xml.attribute(QString::fromLatin1(xml_attribute))); } +/** + @return @a item, or its nearest ancestor, that belongs to a group -- a + click on a symbol's own text hits the text, but the symbol is the member + -- or nullptr if none does. +*/ +QGraphicsItem *ItemGroups::groupedItem(QGraphicsItem *item) +{ + for (; item; item = item->parentItem()) { + if (!groupOf(item).isNull()) { + return item; + } + } + return nullptr; +} + +/** + @return the member a click on @a hit may pick out on its own: the grouped + item hit, when every member of its group is selected already. A first + click selects the whole group; a second click, on a member of the group + it selected, is how the user asks for that member alone (discussion + #1070). nullptr when the click is not that. +*/ +QGraphicsItem *ItemGroups::memberToPick(QGraphicsItem *hit) +{ + QGraphicsItem *member = groupedItem(hit); + if (!member || !member->isSelected() || !member->scene()) { + return nullptr; + } + const QUuid group = groupOf(member); + int members = 0; + for (QGraphicsItem *item : member->scene()->items()) { + if (groupOf(item) == group) { + if (!item->isSelected()) { + return nullptr; + } + ++members; + } + } + return members > 1 ? member : nullptr; +} + /** Make the selection of @a scene whole groups again after it changed. A group with a selected member is selected entirely, except when the diff --git a/sources/itemgroups.h b/sources/itemgroups.h index 7e3c944b1..7e785bd8c 100644 --- a/sources/itemgroups.h +++ b/sources/itemgroups.h @@ -52,6 +52,9 @@ namespace ItemGroups void write(QDomElement &xml, const QGraphicsItem *item); QUuid read(const QDomElement &xml); + QGraphicsItem *groupedItem(QGraphicsItem *item); + QGraphicsItem *memberToPick(QGraphicsItem *hit); + bool completeSelection(QGraphicsScene *scene, const QList &previous, bool toggling); diff --git a/tests/qttest/tst_itemgroups.cpp b/tests/qttest/tst_itemgroups.cpp index 079c8c94c..486c8b82b 100644 --- a/tests/qttest/tst_itemgroups.cpp +++ b/tests/qttest/tst_itemgroups.cpp @@ -121,6 +121,48 @@ private slots: b = nullptr; } + // A second click on a member of a group selected whole picks that member + // out; a click on a member of a group not selected whole does not. + void aMemberOfAWholeGroupCanBePicked() + { + select({a, b}); + QCOMPARE(ItemGroups::memberToPick(a), a); + QCOMPARE(ItemGroups::memberToPick(b), b); + } + + void aMemberOfAPartlySelectedGroupIsNotPicked() + { + select({a}); // after one member was picked + QCOMPARE(ItemGroups::memberToPick(a), nullptr); + select({}); + QCOMPARE(ItemGroups::memberToPick(a), nullptr); + } + + void anUngroupedItemIsNotPicked() + { + select({c}); + QCOMPARE(ItemGroups::memberToPick(c), nullptr); + QCOMPARE(ItemGroups::memberToPick(nullptr), nullptr); + } + + void aGroupOfOneIsNotPicked() + { + ItemGroups::setGroup(e, QUuid()); // g2 is now d alone + select({d}); + QCOMPARE(ItemGroups::memberToPick(d), nullptr); + } + + // A click lands on a symbol's own text, not on the symbol: the member is + // the nearest grouped ancestor. + void aClickOnAMembersChildPicksTheMember() + { + auto child = new QGraphicsRectItem(0, 0, 2, 2, a); + QCOMPARE(ItemGroups::groupedItem(child), a); + select({a, b}); + QCOMPARE(ItemGroups::memberToPick(child), a); + QCOMPARE(ItemGroups::groupedItem(c), nullptr); + } + void xmlRoundTrip() { QDomDocument doc; From a8f940505da151c4506680945cc47b9d77ae6ebb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 22:39:03 +1300 Subject: [PATCH 09/13] Rotate a selected group as one piece Discussion #1070 proposed that rotate, like move, copy and delete, works on the whole group once one of its items is clicked. Rotate (Space) turned each member on its own spot instead, so rotating a group pulled it apart: two grouped texts side by side ended up each turned in place, no longer side by side. When the selection is exactly one whole group -- wires aside, which follow their symbols -- Rotate now turns it as one piece around its centre, as "Pivoter le groupe" (Shift+Space) already does (ItemGroups::soleWholeGroup()). Any other selection, including a single member picked out of its group, rotates as before. In the GUI, on two grouped texts selected by one click: Space on master leaves both where they were, turned; here it gives exactly what Shift+Space gives on both (both texts swung around the group's centre). tst_itemgroups: 4 new checks; without the whole-group condition, a picked member counts as a group and fails. ctest 24/24. Co-Authored-By: Claude Opus 5.5 --- sources/itemgroups.cpp | 31 +++++++++++++++++++++++++++++++ sources/itemgroups.h | 2 ++ sources/qetdiagrameditor.cpp | 13 ++++++++++++- tests/qttest/tst_itemgroups.cpp | 28 ++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/sources/itemgroups.cpp b/sources/itemgroups.cpp index 4e4c7ac9d..ed742ec58 100644 --- a/sources/itemgroups.cpp +++ b/sources/itemgroups.cpp @@ -122,3 +122,34 @@ bool ItemGroups::completeSelection(QGraphicsScene *scene, } return changed; } + +/** + @return the group @a selected is exactly, whole -- every item in it + belongs to that group and every member of the group is in it -- or a null + uuid. Rotating such a selection turns the group as one piece rather than + each member in place (discussion #1070). A member picked out on its own + is not a whole group, and turns in place. + @param selected : the selected items that can be members (the caller + leaves out wires, which follow their symbols) +*/ +QUuid ItemGroups::soleWholeGroup(const QList &selected) +{ + if (selected.isEmpty() || !selected.first()->scene()) { + return QUuid(); + } + const QUuid group = groupOf(selected.first()); + if (group.isNull()) { + return QUuid(); + } + for (QGraphicsItem *item : selected) { + if (groupOf(item) != group) { + return QUuid(); + } + } + for (QGraphicsItem *item : selected.first()->scene()->items()) { + if (groupOf(item) == group && !item->isSelected()) { + return QUuid(); + } + } + return group; +} diff --git a/sources/itemgroups.h b/sources/itemgroups.h index 7e3c944b1..d4e18099d 100644 --- a/sources/itemgroups.h +++ b/sources/itemgroups.h @@ -55,6 +55,8 @@ namespace ItemGroups bool completeSelection(QGraphicsScene *scene, const QList &previous, bool toggling); + + QUuid soleWholeGroup(const QList &selected); } #endif // ITEMGROUPS_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index d65d94b95..80d52f4dd 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -25,6 +25,7 @@ #include "ElementsCollection/elementpickerpopup.h" #include "shortcutbarsettings.h" #include "qetgraphicsitem/conductor.h" +#include "itemgroups.h" #include "commandsearchpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" @@ -2078,7 +2079,17 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action) } else if (value == "rotate_selection") { - RotateSelectionCommand *c = new RotateSelectionCommand(diagram); + //A selection that is exactly one whole group turns as one piece, + //as "Pivoter le groupe" does, rather than each member in place + //(discussion #1070). Wires follow their symbols either way. + QList members; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (item->type() != Conductor::Type) { + members << item; + } + } + const bool whole_group = !ItemGroups::soleWholeGroup(members).isNull(); + RotateSelectionCommand *c = new RotateSelectionCommand(diagram, 90, nullptr, whole_group); if(c->isValid()) diagram->undoStack().push(c); } diff --git a/tests/qttest/tst_itemgroups.cpp b/tests/qttest/tst_itemgroups.cpp index 079c8c94c..b85cadc27 100644 --- a/tests/qttest/tst_itemgroups.cpp +++ b/tests/qttest/tst_itemgroups.cpp @@ -136,6 +136,34 @@ private slots: ItemGroups::setGroup(a, QUuid()); QVERIFY(ItemGroups::groupOf(a).isNull()); } + + // Rotate turns a selection that is exactly one whole group as one piece. + void aWholeGroupAloneIsASoleWholeGroup() + { + select({a, b}); + QCOMPARE(ItemGroups::soleWholeGroup(selection()), g1); + } + + void aPickedMemberIsNotAWholeGroup() + { + select({a}); + QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull()); + } + + void aGroupWithOtherItemsIsNotASoleGroup() + { + select({a, b, c}); + QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull()); + select({a, b, d, e}); + QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull()); + } + + void ungroupedItemsAreNotAGroup() + { + select({c}); + QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull()); + QVERIFY(ItemGroups::soleWholeGroup({}).isNull()); + } }; QTEST_MAIN(tst_itemgroups) From 3d85fc013d438ac33cf77d85adb8faa55903170e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 23:21:38 +1300 Subject: [PATCH 10/13] Scripting: keep conductors()'s doc comment on conductors() The anonymous namespace sat between the comment and the function, so Doxygen attached the comment to describeEnd(). Co-Authored-By: Claude Opus 5.5 --- sources/scripting/qetscriptapi.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index b88d8ee17..8f4182035 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -836,14 +836,6 @@ bool QetScriptApi::addConductor(int folioIndex, return t1->isLinkedTo(t2); } -/** - @brief QetScriptApi::conductors - One line per conductor on the folio: which terminals it joins and its - number, in the form setConductorProperty() addresses them. Descriptive - rather than structured for the same reason elementTerminals() is -- it - exists so a script, or a person reading its output, can see what is - there before changing it. -*/ namespace { /// "{element uuid} terminal N", the form conductors() prints an end in and /// the conductor calls take as element uuid + terminal index. @@ -856,6 +848,14 @@ QString describeEnd(Terminal *t) } } // namespace +/** + @brief QetScriptApi::conductors + One line per conductor on the folio: which terminals it joins and its + number, in the form setConductorProperty() addresses them. Descriptive + rather than structured for the same reason elementTerminals() is -- it + exists so a script, or a person reading its output, can see what is + there before changing it. +*/ QStringList QetScriptApi::conductors(int folioIndex) const { QStringList list; From e7745afd18991ce949d04cae6fad08f981e22cc5 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 23:25:05 +1300 Subject: [PATCH 11/13] qet-mcp: answer review on naming a conductor by uuid - An end conductorEnds() reports as "?" is never picked. - An empty "conductor" says why: an older project's conductors have no saved uuid, so qet_conductors reports it empty. - The op description and README say set_conductor still changes the whole potential when given a uuid, and that older projects' conductors are named by element + terminal until #1103. - test_conductor_by_uuid covers move_conductor_segment. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 7 ++++++- misc/qet-mcp/qet_mcp.py | 13 +++++++++++-- misc/qet-mcp/test_qet_mcp.py | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 08317a00c..486b85379 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -296,7 +296,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in where two conductors meet at a terminal. It is turned at run time into an end whose terminal carries only that conductor; where both of its ends are shared the op fails and its `note` says why. Needs - `qet.conductorEnds()` in the build. + `qet.conductorEnds()` in the build. A uuid names one wire, but + `set_conductor` still changes the whole potential, the same as by + terminal. A project saved before conductors carried a uuid has none in + the file: QElectroTech makes a new one on every load and does not save + it, so `qet_conductors` reports `uuid` as empty and those conductors are + named by `element` + `terminal` until wire uuids last (#1103). - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 0bf06a875..402cb8be1 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1419,6 +1419,7 @@ def _build_script(operations: list, output: str) -> str: " if (ends && ends.length === 2) {", " var lines = qet.conductors(folio);", " for (var k = 0; k < 2; k++) {", + " if (ends[k] === '?') continue;", " var n = 0;", " for (var j = 0; j < lines.length; j++) {", " var p = lines[j].split(' : ')[0].split(' -- ');", @@ -1601,6 +1602,11 @@ def _build_script(operations: list, output: str) -> str: if "element" in op or "terminal" in op: raise ValueError(f"operation {i} ({name}): give either \"conductor\" " f"(its uuid) or \"element\" + \"terminal\", not both") + if op["conductor"] == "": + raise ValueError(f"operation {i}: \"conductor\" is empty -- a conductor " + f"from a project saved before conductors carried a uuid " + f"has none in the file; name it by \"element\" + " + f"\"terminal\" instead") if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]): raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, " f"got {op['conductor']!r}") @@ -2551,8 +2557,11 @@ TOOLS = [ "or give \"conductor\": its uuid from qet_conductors in place " "of \"element\" + \"terminal\" (set_conductor, " "move_conductor_segment and delete_conductor all accept it; it " - "needs one of the conductor's two terminals to carry only it); " - "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + + "needs one of the conductor's two terminals to carry only it, " + "and a conductor qet_conductors lists with an empty uuid has " + "none to give). A uuid names one wire, but set_conductor still " + "changes its whole potential, as it does by terminal. " + "set_conductor's \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + ". move_conductor_segment reroutes the drawn path itself rather " "than a property of the potential -- addressed the same way (a " "terminal carrying exactly one conductor), plus a segment index " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 592dbd9b7..a7a44d76e 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -206,6 +206,12 @@ class EditValidation(unittest.TestCase): s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U, "property": "num", "value": "W1"}]) self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s) + s = self.build([{"op": "move_conductor_segment", "folio": 2, "conductor": U, + "segment": 1, "dx": 10, "dy": 0}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 2, "{U}");', s) + self.assertIn("(e0 ? qet.moveConductorSegment(2, e0.element, e0.terminal, 1, 10, 0) : false)", s) + # an end whose terminal or element is missing is never picked + self.assertIn("if (ends[k] === '?') continue;", s) self.assertNotIn('"conductorEnds"', self.build( [{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}])) with self.assertRaisesRegex(ValueError, "not both"): @@ -213,6 +219,8 @@ class EditValidation(unittest.TestCase): "element": U, "terminal": 0}]) with self.assertRaisesRegex(ValueError, "must be a conductor uuid"): self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}]) + with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) def test_every_op_generates_a_script(self): # one minimal valid instance of every op From e25ae3cbfbcfc0dafc2e034cfd6503dd66459e66 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 23:29:51 +1300 Subject: [PATCH 12/13] Test the second save on every example, a single-space value and accents Review of #1109 (scorpio810): - tst_resaveunchanged now resaves every .qet in examples/ twice instead of two of them (24 rows, ~40 s). Dropping the load-time trim now also fails affuteuse_250h.qet, which the two-file version missed. - New case singleSpaceValueKept: a title-block property set to one space survives two saves (#973), and an accented property comes back unchanged. Fails if qetproject.cpp stops parsing with PreserveSpacingOnlyNodes. - New tst_diagramcontext: the QDom reader (projects) and the pugixml reader (element definitions in the collection) return the same value for plain, stray-spaced, accented and non-Latin text. Fails if the pugixml path decodes as Latin-1 or either reader stops trimming. The pugixml reader still reads a single-space value as "": pugixml drops whitespace-only text unless parse_ws_pcdata is set, as it did before this PR. That reader only sees element definitions, never a project, so #973's title-block values do not go through it. Co-Authored-By: Claude Opus 5.5 --- tests/qttest/CMakeLists.txt | 18 ++++++- tests/qttest/tst_diagramcontext.cpp | 72 ++++++++++++++++++++++++++++ tests/qttest/tst_resaveunchanged.cpp | 48 +++++++++++++++++-- 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 tests/qttest/tst_diagramcontext.cpp diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 525ccb8cd..a32d385f4 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -330,8 +330,8 @@ target_compile_definitions(tst_conductorselfretrace PRIVATE "QET_TEST_BINARY_PATH=\"$\"") # Saving a project that was just saved changes nothing: runs the real -# binary's --resave twice on examples/Projet_vierge.qet (empty information -# values) and examples/m_000.qet (information values with stray spaces). +# binary's --resave twice on every project in examples/, and on one whose +# title block holds a single-space value (#973). add_executable( tst_resaveunchanged tst_resaveunchanged.cpp) @@ -341,3 +341,17 @@ target_link_libraries(tst_resaveunchanged PRIVATE Qt::Test) target_compile_definitions(tst_resaveunchanged PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + +# DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml +# for element definitions in the collection) give the same values: stray +# spaces trimmed, accents kept. +add_executable( + tst_diagramcontext + tst_diagramcontext.cpp + ${QET_DIR}/sources/diagramcontext.cpp + ${QET_DIR}/sources/qet.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/shortcutmanager.cpp) +add_test(NAME tst_diagramcontext COMMAND tst_diagramcontext) +target_include_directories(tst_diagramcontext PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_diagramcontext PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) diff --git a/tests/qttest/tst_diagramcontext.cpp b/tests/qttest/tst_diagramcontext.cpp new file mode 100644 index 000000000..f0e004b0d --- /dev/null +++ b/tests/qttest/tst_diagramcontext.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include "diagramcontext.h" +#include "qetapp.h" + +QString QETApp::m_interface_language; + +/** + DiagramContext::fromXml() has two readers: QDom, for projects, and + pugixml, for element definitions in the collection. Both must give the + values the next save writes -- stray spaces around real content trimmed, + accented characters kept. +*/ +class tst_diagramcontext : public QObject +{ + Q_OBJECT + + static QByteArray xml(const QString &value) + { + return QStringLiteral( + "" + "%1" + "") + .arg(value) + .toUtf8(); + } + + static QString fromDom(const QByteArray &data) + { + QDomDocument doc; + if (!doc.setContent(data)) return QStringLiteral(""); + DiagramContext dc; + dc.fromXml(doc.documentElement(), QStringLiteral("elementInformation")); + return dc.value(QStringLiteral("v")).toString(); + } + + static QString fromPugi(const QByteArray &data) + { + pugi::xml_document doc; + if (!doc.load_buffer(data.constData(), size_t(data.size()))) + return QStringLiteral(""); + DiagramContext dc; + dc.fromXml(doc.document_element(), QStringLiteral("elementInformation")); + return dc.value(QStringLiteral("v")).toString(); + } + +private slots: + void bothReadersAgree_data() + { + QTest::addColumn("value"); + QTest::addColumn("expected"); + + QTest::newRow("plain") << "PRISE" << "PRISE"; + QTest::newRow("stray spaces") << " PRISE " << "PRISE"; + QTest::newRow("accents") << "Armoire façade été" << "Armoire façade été"; + QTest::newRow("accents and stray spaces") << " Moteur à cage " << "Moteur à cage"; + QTest::newRow("non-Latin") << "Двигатель 電機" << "Двигатель 電機"; + } + + void bothReadersAgree() + { + QFETCH(QString, value); + QFETCH(QString, expected); + QCOMPARE(fromDom(xml(value)), expected); + QCOMPARE(fromPugi(xml(value)), expected); + } +}; + +QTEST_APPLESS_MAIN(tst_diagramcontext) + +#include "tst_diagramcontext.moc" diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp index aff0fe360..55fed0942 100644 --- a/tests/qttest/tst_resaveunchanged.cpp +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include // Saving a project that was just saved must change nothing. Two things @@ -16,7 +17,8 @@ // - information values were trimmed on save but not on load, so a label // with stray spaces kept them in its displayed copy until the project // was opened again (m_000.qet). -// Runs the real binary's --resave twice on each example. +// Runs the real binary's --resave twice on every example, and on a +// project whose title block holds a value that is a single space (#973). class tst_resaveunchanged : public QObject { Q_OBJECT @@ -56,17 +58,23 @@ private slots: QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); } + // Projet_vierge.qet has empty information values, m_000.qet values + // with stray spaces; every other example is here so a new cause shows. void secondSaveChangesNothing_data() { QTest::addColumn("project"); - QTest::newRow("empty information values") << QStringLiteral("Projet_vierge.qet"); - QTest::newRow("information values with stray spaces") << QStringLiteral("m_000.qet"); + const QDir examples(QStringLiteral(QET_EXAMPLES_DIR)); + const QStringList projects = + examples.entryList({QStringLiteral("*.qet")}, QDir::Files, QDir::Name); + QVERIFY(!projects.isEmpty()); + for (const QString &project : projects) + QTest::newRow(project.toUtf8().constData()) << examples.filePath(project); } void secondSaveChangesNothing() { QFETCH(QString, project); - const QString first = resave(QStringLiteral(QET_EXAMPLES_DIR "/") + project); + const QString first = resave(project); QVERIFY2(!first.isEmpty(), "first --resave failed"); const QString second = resave(first); QVERIFY2(!second.isEmpty(), "second --resave failed"); @@ -74,6 +82,38 @@ private slots: QVERIFY(!a.isEmpty()); QVERIFY2(a == b, "the second save changed the file"); } + + // A title-block value that is a single space is kept through two saves + // (#973), and a value with accents comes back as it went in. + void singleSpaceValueKept() + { + QByteArray xml = read(QStringLiteral(QET_EXAMPLES_DIR "/Projet_vierge.qet")); + QVERIFY(xml.contains("")); + xml.replace("", + "" + " " + "Armoire façade été"); + const QString in = m_dir.filePath(QStringLiteral("space.qet")); + QFile f(in); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write(xml); + f.close(); + + const QString first = resave(in); + QVERIFY2(!first.isEmpty(), "first --resave failed"); + const QString second = resave(first); + QVERIFY2(!second.isEmpty(), "second --resave failed"); + const QByteArray a = read(first), b = read(second); + QVERIFY2(a == b, "the second save changed the file"); + + const QString saved = QString::fromUtf8(b); + QVERIFY2(saved.contains(QRegularExpression( + QStringLiteral("]*name=\"space\"[^>]*> "))), + "the single-space value was lost"); + QVERIFY2(saved.contains(QRegularExpression( + QStringLiteral("]*name=\"accents\"[^>]*>Armoire façade été"))), + "the accented value changed"); + } }; QTEST_APPLESS_MAIN(tst_resaveunchanged) From cdcff93190422388c58bfab284378721f19ac8dc Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Mon, 28 Sep 2026 16:42:05 +0200 Subject: [PATCH 13/13] Update CMakeLists.txt --- tests/qttest/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b1613d522..ca296b6f8 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -343,6 +343,7 @@ target_compile_definitions(tst_derivedsymboluuid PRIVATE # Saving a project that was just saved changes nothing: runs the real # binary's --resave twice on every project in examples/, and on one whose # title block holds a single-space value (#973). + add_executable( tst_resaveunchanged tst_resaveunchanged.cpp)