From 61bf2e1afaa0b2584f72e6d65607eb4cad58c49b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 21:30:23 +1300 Subject: [PATCH] Test that saving with lazy folios equals saving with every folio built The same 48 edits (industrial.qet and affuteuse_250h.qet, as .qet and .qetz) are made with lazy folios and with QET_EAGER_FOLIOS=1; the saved files must be equal but for the saved date and path. Answers the question raised on #1440 about the hand-kept list of what marks a kept folio stale. Co-Authored-By: Claude Opus 5.5 --- tests/qttest/CMakeLists.txt | 19 ++ tests/qttest/tst_lazysaveparity.cpp | 351 ++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 tests/qttest/tst_lazysaveparity.cpp diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 6dab05931..1304bc356 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1364,3 +1364,22 @@ if(QET_HAS_SCRIPTING) "QET_TEST_BINARY_PATH=\"$\"" "QET_FIXTURES_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures\"") endif() + +# A save with lazy folios equals one with every folio built (discussion +# #1440): the same edits made twice on industrial.qet and affuteuse_250h.qet, +# as .qet and as .qetz, and the saved files compared. The real binary's --run +# and --resave. +if(QET_HAS_SCRIPTING) + add_executable( + tst_lazysaveparity + tst_lazysaveparity.cpp + ${QET_DIR}/sources/container/qetzip.cpp + ${QET_DIR}/sources/container/miniz/miniz.c) + add_test(NAME tst_lazysaveparity COMMAND tst_lazysaveparity) + add_dependencies(tst_lazysaveparity qelectrotech) + target_include_directories(tst_lazysaveparity PRIVATE ${QET_DIR}/sources) + target_link_libraries(tst_lazysaveparity PRIVATE Qt::Test SQLite3::SQLite3) + target_compile_definitions(tst_lazysaveparity PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +endif() diff --git a/tests/qttest/tst_lazysaveparity.cpp b/tests/qttest/tst_lazysaveparity.cpp new file mode 100644 index 000000000..e459e341f --- /dev/null +++ b/tests/qttest/tst_lazysaveparity.cpp @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "container/qetzip.h" + +#include + +// With lazy folios (the default) a folio that was not built is saved as it +// was read, unless something marked it stale: a list kept by hand in +// QETProject (markKeptFoliosStale() and its callers, setProjectProperties(), +// the conditions in toXml()). An edit that list misses loses data without a +// word (discussion #1440). So each edit is made twice on the same project, +// once lazily and once with QET_EAGER_FOLIOS=1 (every folio built, nothing +// kept), and the two saved files must be the same but for what a save says of +// itself (saved date and path). +// The real binary's --run on two examples, resaved with every folio built as +// a .qet and as a .qetz first, so both kept paths are tried: a .qet's folio +// text and a .qetz's folio parts. +// +// Edits left out, each a difference between the two saves that is not the +// lazy one losing data: +// - folio-remove, folio-remove-0: the save with every folio built is the +// wrong one, it keeps links and labels that point to the folio (F113); +// - folio-label: the label follows the new folio name, and right-aligned +// text moves (F120); +// - folio-insert-0, folio-insert-mid: labels, wire numbers and report texts +// made from the folio number or the total are made again when every folio +// is built and not when it is kept; the lazy save is the right one (F122); +// - a table whose chain head sits on a removed folio (F121). +class tst_lazysaveparity : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + // Each example saved with every folio built, by name and suffix + QHash m_sources; + + // What the --run scripts have in common: the middle folio, and finding + // the same item on it whichever folios are built (lists are sorted: + // the order of a folio's items differs with its neighbours built) + static QByteArray prelude() + { + return R"JS( +var n = qet.folioCount(); +var f = Math.floor(n / 2); +function sorted(list) { return list.slice().sort(); } +function symbol(kinds) { + for (var k = 0 ; k < n ; ++k) { + var g = (Math.floor(n / 2) + k) % n; + var u = sorted(qet.elementUuids(g)); + for (var i = 0 ; i < u.length ; ++i) + if (kinds.indexOf(qet.elementLinkType(g, u[i])) >= 0 && qet.linkedElements(g, u[i]).length) { + f = g; + return u[i]; + } + } + return ''; +} +function autoNumName(entry) { return entry.substring(0, entry.lastIndexOf(": formula=")); } +function first() { var u = sorted(qet.elementUuids(f)); return u.length ? u[0] : ''; } +var added = -1; +function wire() { var u = sorted(qet.conductorUuids(f)); return u.length ? u[0] : ''; } +)JS"; + } + + // QElectroTech run in a sandbox of its own, with @p args: its own HOME + // and TMPDIR, as SingleApplication takes two launches of the same binary + // for one application and hands the second to the first. Returns the + // output; @p ok tells it finished. + QString runQet(const QStringList &args, bool eager, bool *ok = nullptr) + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run)); + const QString tmp = m_dir.filePath(QStringLiteral("t%1").arg(m_run++)); + QDir().mkpath(home); + QDir().mkpath(tmp); + 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")); + env.insert(QStringLiteral("XDG_CACHE_HOME"), home + QStringLiteral("/cache")); + env.insert(QStringLiteral("TMPDIR"), tmp); + env.remove(QStringLiteral("QET_LAZY_FOLIOS")); + if (eager) + env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1")); + else + env.remove(QStringLiteral("QET_EAGER_FOLIOS")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args); + const bool done = proc.waitForFinished(180000); + if (ok) *ok = done && proc.exitStatus() == QProcess::NormalExit; + return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + } + + static QByteArray read(const QString &path) + { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + + static const QString zero() + { + return QStringLiteral("00000000-0000-0000-0000-000000000000"); + } + + // The lines of @p text without what a save says of itself, and with + // as (F114: the same document written two ways); @p mask, + // a folio's random uuid, as zeros + static QStringList lines(QString text, const QString &mask) + { + if (!mask.isEmpty()) + text.replace(mask, zero()); + static const QRegularExpression empty(QStringLiteral("<([\\w-]+)([^<>]*)>\\s*")); + text.replace(empty, QStringLiteral("<\\1\\2/>")); + QStringList out; + const QStringList all = text.split(QLatin1Char('\n')); + for (const QString &l : all) + if (!l.contains(QLatin1String("name=\"saved"))) + out << l; + return out; + } + + // Every row of every table of the SQLite file @p data, one line each, + // but the rows that say when the file was saved + static QStringList databaseRows(const QByteArray &data, const QString &mask) + { + QStringList out; + sqlite3 *db = nullptr; + sqlite3_open(":memory:", &db); + unsigned char *copy = static_cast(sqlite3_malloc64(sqlite3_uint64(data.size()))); + memcpy(copy, data.constData(), size_t(data.size())); + sqlite3_deserialize(db, "main", copy, data.size(), data.size(), SQLITE_DESERIALIZE_FREEONCLOSE); + auto text = [](sqlite3_stmt *st, int col) { + const unsigned char *t = sqlite3_column_text(st, col); + return t ? QString::fromUtf8(reinterpret_cast(t)) : QStringLiteral("NULL"); + }; + QStringList tables; + sqlite3_stmt *list = nullptr; + sqlite3_prepare_v2(db, "SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name", -1, &list, nullptr); + while (list && sqlite3_step(list) == SQLITE_ROW) { + tables << text(list, 0); + out << text(list, 1); + } + sqlite3_finalize(list); + for (const QString &table : std::as_const(tables)) { + sqlite3_stmt *st = nullptr; + const QByteArray sql = "SELECT * FROM \"" + table.toUtf8() + "\" ORDER BY rowid"; + if (sqlite3_prepare_v2(db, sql.constData(), -1, &st, nullptr) != SQLITE_OK) { + sqlite3_finalize(st); + continue; + } + while (sqlite3_step(st) == SQLITE_ROW) { + QStringList row{table}; + bool saved = false; + for (int c = 0 ; c < sqlite3_column_count(st) ; ++c) { + row << text(st, c); + saved = saved || row.last().startsWith(QLatin1String("saved")); + } + if (!saved) { + QString line = row.join(QLatin1Char('|')); + if (!mask.isEmpty()) + line.replace(mask, zero()); + out << line; + } + } + sqlite3_finalize(st); + } + sqlite3_close(db); + return out; + } + + // What the saved file @p path holds but what a save says of itself, as + // lines: a .qet's text; a .qetz's entries, XML as text, the database as + // its rows, anything else (pictures) as a hash + static QStringList contents(const QString &path, const QString &mask, bool *ok) + { + const QByteArray bytes = read(path); + *ok = !bytes.isEmpty(); + if (!path.endsWith(QLatin1String(".qetz"))) + return lines(QString::fromUtf8(bytes), mask); + QList entries; + if (!QetZip::fromBytes(bytes, &entries)) { + *ok = false; + return {}; + } + // By name: the order of the entries is not part of the project + QMap byName; + for (const QetZip::Entry &e : std::as_const(entries)) { + QString name = e.name; + if (!mask.isEmpty()) + name.replace(mask, zero()); + if (e.name.endsWith(QLatin1String(".sqlite"))) + byName[name] = databaseRows(e.data, mask); + else if (e.name.endsWith(QLatin1String(".xml"))) + byName[name] = lines(QString::fromUtf8(e.data), mask); + else + byName[name] = {QString::fromLatin1(QCryptographicHash::hash(e.data, QCryptographicHash::Sha1).toHex())}; + } + QStringList out; + for (auto it = byName.cbegin() ; it != byName.cend() ; ++it) + out << QStringLiteral("#### ") + it.key() << it.value(); + return out; + } + + // The first line where @p a and @p b differ, to say in a failure + static QString firstDifference(const QStringList &a, const QStringList &b) + { + for (int i = 0 ; i < qMax(a.size(), b.size()) ; ++i) { + const QString x = i < a.size() ? a.at(i) : QStringLiteral(""); + const QString y = i < b.size() ? b.at(i) : QStringLiteral(""); + if (x != y) { + QString where; + for (int k = i ; k >= 0 && k < a.size() ; --k) + if (a.at(k).startsWith(QLatin1String("#### "))) { + where = a.at(k); + break; + } + return QStringLiteral("line %1 %2: lazy [%3] eager [%4]") + .arg(i).arg(where, x.left(300), y.left(300)); + } + } + return QString(); + } + + // The edit @p body (a function body returning true when it applied) made + // on @p source and saved as @p out; false when it did not apply or + // nothing was saved. @p mask gets the uuid of a folio the edit adds. + bool edit(const QString &source, const QString &body, const QString &out, bool eager, QString *mask) + { + const QString script = out + QStringLiteral(".js"); + QFile f(script); + if (!f.open(QIODevice::WriteOnly)) + return false; + f.write(prelude()); + f.write("var ok = (function() { " + body.toUtf8() + " })();\n" + "qet.log('EDITDIFF ' + ok);\n" + "if (ok && added >= 0) qet.log('EDITDIFF-UUID ' + qet.folioUuid(added));\n" + "if (ok) qet.save('" + out.toUtf8() + "');\n"); + f.close(); + bool ran = false; + const QString log = runQet({QStringLiteral("--run"), script, source}, eager, &ran); + const auto m = QRegularExpression(QStringLiteral("EDITDIFF-UUID \\{([0-9a-f-]+)\\}")).match(log); + *mask = m.hasMatch() ? m.captured(1) : QString(); + return ran && log.contains(QLatin1String("EDITDIFF true")) && QFileInfo(out).size() > 0; + } + + struct Op { const char *name; const char *body; }; + // Each runs on the middle folio (stages 1a and 1b of + // LAZY-SAVE-PARITY-SCOPE.md tried more; these are ones that save the same + // on both examples, one of each kind, to keep the run short: every row + // opens the project twice, 3 to 4 s each on industrial.qet) + static QList ops(bool industrial) + { + QList list{ + {"folio-title", "return n > 1 && qet.setFolioProperty(f, 'title', 'Edited title');"}, + {"master-move", "var u = symbol(['master']); return !!u && qet.moveElement(f, u, 200, 0);"}, + {"master-unlink", "var u = symbol(['master']); return !!u && qet.unlinkElement(f, u);"}, + {"slave-delete", "var u = symbol(['slave']); return !!u && qet.deleteElement(f, u);"}, + {"report-delete", "var u = symbol(['next_report', 'previous_report']); return !!u && qet.deleteElement(f, u);"}, + {"symbol-comment", "var u = first(); return !!u && qet.setElementInfo(f, u, 'comment', 'edited');"}, + {"wire-function", "var c = wire(); return !!c && qet.setConductorPropertyByUuid(f, c, 'function', 'edited');"}, + {"folio-add", "added = qet.addFolio(); return added >= 0;"}, + {"project-title", "return qet.setProjectTitle('Edited project title');"}, + }; + if (industrial) { + // master-delete is unstable on affuteuse_250h, and it has no + // automatic numbering to renumber + list << Op{"master-delete", "var u = symbol(['master']); return !!u && qet.deleteElement(f, u);"}; + list << Op{"renumber", "var a = qet.autoNums('element'); return a.length > 0 && qet.renumberElementAutoNum(autoNumName(a[0])) >= 0;"}; + } else { + // The labels and wire numbers of industrial.qet come from + // formulas, which write over a label or number set by hand: the + // edit changes nothing there (the guard in saveIsTheSame()) + list << Op{"master-label", "var u = symbol(['master']); return !!u && qet.setElementLabel(f, u, 'KX99');"}; + list << Op{"symbol-label", "var u = first(); return !!u && qet.setElementLabel(f, u, 'QX1');"}; + list << Op{"wire-num", "var c = wire(); return !!c && qet.setConductorPropertyByUuid(f, c, 'num', 'W99');"}; + } + return list; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + for (const char *name : {"industrial", "affuteuse_250h"}) + for (const char *suffix : {".qet", ".qetz"}) { + const QString key = QLatin1String(name) + QLatin1String(suffix); + const QString dir = m_dir.filePath(QStringLiteral("src-") + key); + QDir().mkpath(dir); + const QString out = dir + QStringLiteral("/") + key; + runQet({QStringLiteral("--resave"), + QStringLiteral(QET_EXAMPLES_DIR "/") + QLatin1String(name) + QStringLiteral(".qet"), out}, + true); + QVERIFY2(QFileInfo(out).size() > 0, qPrintable(QStringLiteral("cannot resave ") + key)); + m_sources.insert(key, out); + } + } + + void saveIsTheSame_data() + { + QTest::addColumn("source"); + QTest::addColumn("op"); + QTest::addColumn("body"); + for (const char *name : {"industrial", "affuteuse_250h"}) + for (const char *suffix : {".qet", ".qetz"}) + for (const Op &op : ops(QLatin1String(name) == QLatin1String("industrial"))) + QTest::newRow(qPrintable(QLatin1String(name) + QLatin1String(suffix) + QLatin1Char(' ') + QLatin1String(op.name))) + << QLatin1String(name) + QLatin1String(suffix) + << QString::fromLatin1(op.name) << QString::fromLatin1(op.body); + } + + void saveIsTheSame() + { + QFETCH(QString, source); + QFETCH(QString, op); + QFETCH(QString, body); + const QString suffix = source.mid(source.indexOf(QLatin1Char('.'))); + const QString in = m_sources.value(source); + const QString lazy = m_dir.filePath(QStringLiteral("out-lazy-%1-%2").arg(source, op)) + suffix; + const QString eager = m_dir.filePath(QStringLiteral("out-eager-%1-%2").arg(source, op)) + suffix; + QString lazyMask, eagerMask; + QVERIFY2(edit(in, body, lazy, false, &lazyMask), "the edit did not apply or save with lazy folios"); + QVERIFY2(edit(in, body, eager, true, &eagerMask), "the edit did not apply or save with every folio built"); + + bool ok1 = false, ok2 = false, ok3 = false; + const QStringList a = contents(lazy, lazyMask, &ok1); + const QStringList b = contents(eager, eagerMask, &ok2); + const QStringList original = contents(in, QString(), &ok3); + QVERIFY2(ok1 && ok2 && ok3, "a saved file cannot be read"); + // The edit must be seen in the file: two saves of an edit that did + // nothing would be the same whatever lazy folios do + QVERIFY2(b != original, "the edit changed nothing in the save with every folio built"); + QVERIFY2(a == b, qPrintable(firstDifference(a, b))); + } +}; + +QTEST_APPLESS_MAIN(tst_lazysaveparity) + +#include "tst_lazysaveparity.moc"