Merge master into fix/joined-wire-uuid

The conflict was tests/qttest/CMakeLists.txt, where #1520 added
tst_savedstamps at the place this branch adds tst_joinedwireuuid. Both
are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-18 20:50:24 +13:00
13 changed files with 545 additions and 113 deletions
+12
View File
@@ -889,6 +889,18 @@ if(QET_HAS_SCRIPTING)
target_compile_definitions(tst_scriptconductoruuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# The saved date, time and path of the title block change only when the
# project changed, and go into the file that save writes (F124): an
# unchanged project saved twice gives an identical file.
add_executable(
tst_savedstamps
tst_savedstamps.cpp)
add_test(NAME tst_savedstamps COMMAND tst_savedstamps)
add_dependencies(tst_savedstamps qelectrotech)
target_link_libraries(tst_savedstamps PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_savedstamps PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# Deleting a symbol whose terminal held several wires joins them with a
# new wire; its uuid comes from its two ends, so the same deletion saves
# the same file every time (F115): examples/affuteuse_250h.qet.
+3 -1
View File
@@ -214,7 +214,9 @@ class tst_projectlock : public QObject
void readOnlyFolderDoesNotBlock()
{
#ifndef Q_OS_WIN
#ifdef Q_OS_WIN
QSKIP("a read-only folder still takes new files on Windows");
#else
if (geteuid() == 0)
QSKIP("root can write anywhere");
#endif
+6 -3
View File
@@ -69,10 +69,12 @@ private slots:
f.close();
// Columns numbered from 1, as QElectroTech's settings page
// shows by default
// shows by default. Written to an INI file QET_SETTINGS_DIR
// points at: on Windows the settings are in the registry
// otherwise, and the columns would count from 0
const QString home = m_dir.filePath(tag + QStringLiteral("-home"));
QVERIFY(QDir().mkpath(home + QStringLiteral("/config/QElectroTech")));
QFile conf(home + QStringLiteral("/config/QElectroTech/QElectroTech.conf"));
QVERIFY(QDir().mkpath(home + QStringLiteral("/settings/QElectroTech")));
QFile conf(home + QStringLiteral("/settings/QElectroTech/QElectroTech.ini"));
QVERIFY(conf.open(QIODevice::WriteOnly));
conf.write("[General]\nborder-columns_0=false\n");
conf.close();
@@ -80,6 +82,7 @@ private slots:
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
env.insert(QStringLiteral("QET_SETTINGS_DIR"), home + QStringLiteral("/settings"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
+28 -2
View File
@@ -411,14 +411,15 @@ private slots:
// QElectroTech run in a sandbox of its own with @p args, the store
// check on (QET_CHECK_ELEMENT_INFO_STORE); its output
QString runChecked(const QStringList &args, bool eager = false)
QString runChecked(const QStringList &args, bool eager = false,
const char *check = "QET_CHECK_ELEMENT_INFO_STORE")
{
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("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
env.insert(QStringLiteral("QET_CHECK_ELEMENT_INFO_STORE"), QStringLiteral("1"));
env.insert(QString::fromLatin1(check), QStringLiteral("1"));
if (eager) {
env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1")); //every folio built on opening
}
@@ -452,6 +453,31 @@ private slots:
QVERIFY2(out.contains(QStringLiteral("wire properties store: 0 differ")), qPrintable(out.right(600)));
}
// The link rows say what each symbol saves, in its order, also after
// an edit builds a partner's folio (lazy folios): m_000 as a .qetz
// saved with every folio built, a symbol of its middle folio moved. The rows used to follow the order
// the partners were built in (QETZ-DB-FIRST stage 2a).
void linkRowsAfterLazyEdit()
{
const QString qetz = m_dir.filePath(QStringLiteral("links%1.qetz").arg(m_run));
QVERIFY(!runChecked({QStringLiteral("--resave"),
QStringLiteral(QET_EXAMPLES_DIR "/m_000.qet"), qetz}, true).isEmpty());
const QString saved = m_dir.filePath(QStringLiteral("links-moved%1.qetz").arg(m_run));
const QString js = m_dir.filePath(QStringLiteral("links%1.js").arg(m_run));
QFile script(js);
QVERIFY(script.open(QIODevice::WriteOnly));
script.write(QStringLiteral(
"var f = Math.floor(qet.folioCount() / 2);\n"
"var u = qet.elementUuids(f).slice().sort()[0];\n"
"qet.log('MOVED ' + qet.moveElement(f, u, 0, 100));\n"
"qet.save('%1');\n").arg(saved).toUtf8());
script.close();
const QString out = runChecked({QStringLiteral("--run"), js, qetz}, false,
"QET_CHECK_LINK_ROWS");
QVERIFY2(out.contains(QStringLiteral("MOVED true")), qPrintable(out.right(600)));
QVERIFY2(out.contains(QStringLiteral("link rows: 0 differ")), qPrintable(out.right(600)));
}
// The stores filled from the document before the folios are built
// hold what building them gives, for a
// project saved by this version (every symbol, folio and wire carries
+143
View File
@@ -0,0 +1,143 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QtTest>
#include <QDate>
#include <QDomDocument>
#include <QProcess>
#include <QTemporaryDir>
/**
The title block variables of a save (saveddate, savedtime,
savedfilename, savedfilepath...) are set when the project is saved
with something new in it, and go into the file that save writes. They
used to be set after writing, so each save wrote the time of the one
before: a project saved twice without a change gave two different
files, and a changed project was saved with the previous date.
Runs the real binary on fixtures/unlinked_contact_label.qet, whose
stamps say 2026-10-02 18:39 in a temporary folder:
- saved twice in its own file without a change: byte-identical;
- one symbol moved, then saved: today's date and the file's own path.
*/
class tst_savedstamps : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
QString m_base;
QString run(const QStringList &arguments)
{
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
//Its own settings and single-instance socket: another test's
//QElectroTech running at the same time would otherwise be
//handed this run (SingleApplication)
const QString home = m_dir.filePath(QStringLiteral("home"));
QDir().mkpath(home);
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.setProcessChannelMode(QProcess::MergedChannels);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), arguments);
if (!proc.waitForFinished(120000) || proc.exitStatus() != QProcess::NormalExit
|| proc.exitCode() != 0)
return QString();
return QString::fromUtf8(proc.readAll()) + QStringLiteral("\nEXIT 0");
}
QString script(const QString &name, const QString &body)
{
const QString path = m_dir.filePath(name);
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) return QString();
file.write(body.toUtf8());
return path;
}
/// The saved-* properties of @p qetz, read back through --resave
QMap<QString, QString> stamps(const QString &qetz)
{
const QString xml = qetz + QStringLiteral(".qet");
if (run({QStringLiteral("--resave"), qetz, xml}).isEmpty()) return {};
QFile file(xml);
QDomDocument doc;
if (!file.open(QIODevice::ReadOnly) || !doc.setContent(&file)) return {};
QMap<QString, QString> out;
const QDomNodeList props = doc.documentElement().firstChildElement(
QStringLiteral("properties")).elementsByTagName(QStringLiteral("property"));
for (int i = 0; i < props.size(); ++i) {
const QDomElement p = props.at(i).toElement();
if (p.attribute(QStringLiteral("name")).startsWith(QLatin1String("saved")))
out.insert(p.attribute(QStringLiteral("name")), p.text());
}
return out;
}
static QByteArray bytes(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
private slots:
void initTestCase()
{
QVERIFY2(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)), "qelectrotech binary not found");
QVERIFY(m_dir.isValid());
const QString fixture = QFINDTESTDATA("fixtures/unlinked_contact_label.qet");
QVERIFY2(!fixture.isEmpty(), "fixture project not found");
//The fixture as a .qetz, its stamps as they were: a copy
//written by --resave is not a save of the project
m_base = m_dir.filePath(QStringLiteral("base.qetz"));
QVERIFY(!run({QStringLiteral("--resave"), fixture, m_base}).isEmpty());
QCOMPARE(stamps(m_base).value(QStringLiteral("saveddate-us")), QStringLiteral("2026-10-02"));
}
void unchangedSavesAreIdentical()
{
const QString project = m_dir.filePath(QStringLiteral("unchanged.qetz"));
QVERIFY(QFile::copy(m_base, project));
const QString js = script(QStringLiteral("unchanged.js"),
QStringLiteral("qet.save('');\nqet.save('');\nqet.log('SAVED twice');\n"));
QVERIFY(run({QStringLiteral("--run"), js, project}).contains(QLatin1String("SAVED twice")));
QCOMPARE(bytes(project), bytes(m_base));
}
void changedSaveIsStampedNow()
{
const QString project = m_dir.filePath(QStringLiteral("changed.qetz"));
QVERIFY(QFile::copy(m_base, project));
const QString js = script(QStringLiteral("changed.js"), QStringLiteral(
"var u = qet.elementUuids(0)[0];\n"
"qet.log('MOVED ' + qet.moveElement(0, u, 10, 0));\n"
"qet.save('');\n"));
QVERIFY(run({QStringLiteral("--run"), js, project}).contains(QLatin1String("MOVED true")));
const QMap<QString, QString> s = stamps(project);
QCOMPARE(s.value(QStringLiteral("saveddate-us")), QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")));
QCOMPARE(s.value(QStringLiteral("savedfilepath")), QFileInfo(project).absoluteFilePath());
QCOMPARE(s.value(QStringLiteral("savedfilename")), QStringLiteral("changed"));
}
};
QTEST_GUILESS_MAIN(tst_savedstamps)
#include "tst_savedstamps.moc"
+60 -6
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QSet>
#include <QDomDocument>
#include <QFile>
@@ -150,13 +151,66 @@ private slots:
QVERIFY(!twice.isEmpty());
QCOMPARE(nets(twice), expected);
//No wire names a lamp by the uuid they share
for (const QDomElement &c : childElements(firstDiagram(load(twice)),
QStringLiteral("conductors"),
QStringLiteral("conductor"))) {
QVERIFY(c.attribute(QStringLiteral("element1")) != shared);
QVERIFY(c.attribute(QStringLiteral("element2")) != shared);
//Each lamp has a uuid of its own after a save: one keeps the
//shared uuid, the others get one derived from it
QSet<QString> uuids;
for (const QDomElement &e : lamps(load(twice)))
uuids.insert(e.attribute(QStringLiteral("uuid")));
QCOMPARE(uuids.size(), 3);
QVERIFY(uuids.contains(shared));
}
// A linked contact and an unlinked symbol carrying the same uuid (a copy
// made by an older version): the linked one keeps the uuid, the other
// gets its own, and every link and wire stays where it was.
void linkedCopyKeepsUuid()
{
const QString source = QStringLiteral(QET_EXAMPLES_DIR "/affuteuse_250h.qet");
QDomDocument doc = load(source);
QList<QDomElement> all;
const QDomNodeList folios = doc.documentElement().elementsByTagName(QStringLiteral("diagram"));
for (int i = 0; i < folios.size(); ++i)
all << childElements(folios.at(i).toElement(), QStringLiteral("elements"),
QStringLiteral("element"));
auto linked = [](const QDomElement &e) {
return !e.firstChildElement(QStringLiteral("links_uuids"))
.firstChildElement(QStringLiteral("link_uuid")).isNull();
};
QDomElement keeper, copy;
for (const QDomElement &e : std::as_const(all)) {
if (keeper.isNull() && linked(e)) keeper = e;
else if (copy.isNull() && !linked(e)) copy = e;
}
QVERIFY(!keeper.isNull() && !copy.isNull());
const QString shared = keeper.attribute(QStringLiteral("uuid"));
copy.setAttribute(QStringLiteral("uuid"), shared);
const int wires = doc.elementsByTagName(QStringLiteral("conductor")).size();
const QString saved = resave(write(doc));
QVERIFY(!saved.isEmpty());
const QDomDocument out = load(saved);
QHash<QString, QDomElement> by_uuid;
QList<std::pair<QString, QString>> links;
const QDomNodeList saved_folios = out.documentElement().elementsByTagName(QStringLiteral("diagram"));
for (int i = 0; i < saved_folios.size(); ++i)
for (const QDomElement &e : childElements(saved_folios.at(i).toElement(),
QStringLiteral("elements"),
QStringLiteral("element"))) {
const QString u = e.attribute(QStringLiteral("uuid"));
QVERIFY2(!by_uuid.contains(u), qPrintable(QStringLiteral("uuid still shared: ") + u));
by_uuid.insert(u, e);
for (QDomElement l = e.firstChildElement(QStringLiteral("links_uuids"))
.firstChildElement(QStringLiteral("link_uuid"));
!l.isNull(); l = l.nextSiblingElement(QStringLiteral("link_uuid")))
links.append({u, l.attribute(QStringLiteral("uuid"))});
}
QVERIFY(by_uuid.contains(shared));
QVERIFY(linked(by_uuid.value(shared)));
for (const auto &link : std::as_const(links)) {
QVERIFY2(by_uuid.contains(link.second), "a link names a symbol not in the project");
QVERIFY2(links.contains(std::make_pair(link.second, link.first)), "a link only one side has");
}
QCOMPARE(out.elementsByTagName(QStringLiteral("conductor")).size(), wires);
}
void savedByUuidReopensRight()