Files
qelectrotech-source-mirror/tests/qttest/tst_databasefromdocument.cpp
T
ispyisail 5e6677ebec Keep the project database current when folios, symbols and wires change
The project database is kept up to date edit by edit, and a script's
qet.query() and the folio tables read it without a full rebuild. Three
edits left it out of date until the next rebuild:

- adding or removing a folio: the folios after it kept their old
  position and folio number, and a removed folio's diagram_info row
  stayed;
- moving a symbol: its folio cell (element.pos) stayed where the symbol
  was added;
- deleting a wire: its two ends stayed in the terminal table, which
  lists the terminals a wire ends on.

Positions and folio numbers are now written again after a folio is
added, removed or moved (one function for the three), the removed
folio's diagram_info row goes, a moved symbol's cell is written when
the database is next read (as drawing items are), and a deleted wire's
ends go unless another wire ends there.

Found by comparing the tables after random scripted edits with those
a fresh open of the saved project gives. Left alone: labels and wire
numbers made from a folio formula, which the drawing itself keeps until
the project is reopened.

Tests (tst_databasefromdocument): folioAddedAndRemoved,
movedElementCell, deletedWireTerminals; each fails without its part of
the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Wga9B92BNurDEtxxtCDxf
2026-10-10 19:07:03 +13:00

466 lines
21 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QDomDocument>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A project's database, filled from the file as it is read, holds exactly
// what the same database filled from the built folios holds. Every example
// is saved once (so that it carries the uuids a current QElectroTech
// writes), then opened twice through the real binary's --run: once as is,
// once with QET_DATABASE_FROM_FOLIOS=1, and the seven tables compared.
class tst_databasefromdocument : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
QProcessEnvironment env()
{
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QProcessEnvironment e = QProcessEnvironment::systemEnvironment();
e.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
e.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
e.insert(QStringLiteral("HOME"), home);
e.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
e.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
e.insert(QStringLiteral("TMPDIR"), m_dir.path());
e.remove(QStringLiteral("QET_DATABASE_FROM_FOLIOS"));
return e;
}
QString run(const QStringList &args, bool from_folios = false)
{
QProcessEnvironment e = env();
if (from_folios)
e.insert(QStringLiteral("QET_DATABASE_FROM_FOLIOS"), QStringLiteral("1"));
QProcess proc;
proc.setProcessEnvironment(e);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args);
if (!proc.waitForFinished(180000)) return {};
return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError());
}
// The seven tables, each as a sorted list of its rows, and which way the
// database was filled.
QJsonObject dump(const QString &project, bool from_folios, QString *how)
{
const QString out = run({QStringLiteral("--run"), m_dir.filePath(QStringLiteral("dump.js")),
project}, from_folios);
return tablesIn(out, how);
}
// The DUMP line of a --run output, each table as a sorted list of rows
static QJsonObject tablesIn(const QString &out, QString *how)
{
QJsonObject tables;
for (const QString &line : out.split(QLatin1Char('\n'))) {
if (line.contains(QStringLiteral("Project database filled")))
*how = line.mid(line.indexOf(QStringLiteral("Project database filled")));
const int i = line.indexOf(QStringLiteral("DUMP "));
if (i >= 0) {
const QJsonObject raw = QJsonDocument::fromJson(line.mid(i + 5).toUtf8()).object();
for (auto it = raw.begin(); it != raw.end(); ++it) {
QStringList rows;
for (const QJsonValue &row : it.value().toArray())
rows << QString::fromUtf8(QJsonDocument(row.toObject()).toJson(QJsonDocument::Compact));
rows.sort();
tables.insert(it.key(), QJsonArray::fromStringList(rows));
}
}
}
return tables;
}
// tremie_vibrante.qet saved once, as a document to change
QDomDocument resaved(const QString &saved)
{
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved});
QFile file(saved);
QDomDocument document;
if (file.open(QIODevice::ReadOnly))
document.setContent(&file);
return document;
}
static bool write(const QString &path, const QDomDocument &document)
{
QFile file(path);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
&& file.write(document.toByteArray()) > 0;
}
// Both fills of @p saved, which must come from the document, agree.
void compareBothWays(const QString &saved)
{
QString how_document, how_folios;
const QJsonObject document = dump(saved, false, &how_document);
const QJsonObject folios = dump(saved, true, &how_folios);
QVERIFY2(how_document == QLatin1String("Project database filled from the document"),
qPrintable(how_document));
QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios));
QCOMPARE(document.keys().size(), 7);
for (const QString &table : folios.keys()) {
const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray();
QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios")
.arg(table).arg(a.size()).arg(b.size())));
}
m_last = document;
}
QJsonObject m_last; // the document's tables, from compareBothWays()
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
QFile js(m_dir.filePath(QStringLiteral("dump.js")));
QVERIFY(js.open(QIODevice::WriteOnly));
js.write("var out = {};\n"
"['diagram', 'diagram_info', 'element', 'element_info', 'terminal', 'conductor', 'link']"
".forEach(function (t) { out[t] = qet.query('SELECT * FROM ' + t); });\n"
"qet.log('DUMP ' + JSON.stringify(out));\n");
}
void sameTablesBothWays_data()
{
QTest::addColumn<QString>("project");
const QDir examples(QStringLiteral(QET_EXAMPLES_DIR));
for (const QString &f : examples.entryList({QStringLiteral("*.qet")}, QDir::Files, QDir::Name))
QTest::newRow(f.toUtf8().constData()) << examples.filePath(f);
}
void sameTablesBothWays()
{
QFETCH(QString, project);
const QString saved = m_dir.filePath(QStringLiteral("saved%1.qet").arg(m_run));
run({QStringLiteral("--resave"), project, saved});
QVERIFY2(QFile::exists(saved), "--resave failed");
compareBothWays(saved);
}
// A label or a conductor text made from a formula is what the formula
// gives now, not what it gave when the file was saved: every folio's
// first element and first conductor is given a formula using each kind
// of variable, and a saved label no formula gives.
void formulasAreWorkedOut()
{
const QString saved = m_dir.filePath(QStringLiteral("formulas-saved.qet"));
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved});
QFile file(saved);
QVERIFY(file.open(QIODevice::ReadOnly));
QDomDocument document;
QVERIFY(document.setContent(&file));
file.close();
auto property = [&document](QDomElement parent, const QString &name, const QString &value) {
QDomElement properties = parent.firstChildElement(QStringLiteral("properties"));
if (properties.isNull())
properties = parent.appendChild(document.createElement(QStringLiteral("properties"))).toElement();
QDomElement p = document.createElement(QStringLiteral("property"));
p.setAttribute(QStringLiteral("name"), name);
p.appendChild(document.createTextNode(value));
properties.appendChild(p);
};
auto sequence = [&document](QDomElement item, const QString &unit) {
item.removeChild(item.firstChildElement(QStringLiteral("sequentialNumbers")));
QDomElement s = document.createElement(QStringLiteral("sequentialNumbers"));
QDomElement u = document.createElement(QStringLiteral("unit"));
u.appendChild(document.createTextNode(unit));
s.appendChild(u);
item.appendChild(s);
};
property(document.documentElement(), QStringLiteral("site"), QStringLiteral("S"));
const QDomNodeList diagrams = document.elementsByTagName(QStringLiteral("diagram"));
QCOMPARE(diagrams.size(), 3);
for (int i = 0 ; i < diagrams.size() ; ++i)
{
QDomElement diagram = diagrams.at(i).toElement();
diagram.setAttribute(QStringLiteral("folio"), QStringLiteral("F%id"));
diagram.setAttribute(QStringLiteral("plant"), QStringLiteral("P"));
diagram.setAttribute(QStringLiteral("locmach"), QStringLiteral("L"));
property(diagram, QStringLiteral("zone"), QStringLiteral("Z%1").arg(i));
QDomElement element = diagram.firstChildElement(QStringLiteral("elements"))
.firstChildElement(QStringLiteral("element"));
QVERIFY(!element.isNull());
element.setAttribute(QStringLiteral("prefix"), QStringLiteral("X"));
sequence(element, QStringLiteral("7"));
QDomElement informations = element.firstChildElement(QStringLiteral("elementInformations"));
if (informations.isNull())
informations = element.appendChild(document.createElement(QStringLiteral("elementInformations"))).toElement();
while (!informations.firstChild().isNull())
informations.removeChild(informations.firstChild());
for (const auto &info : {std::make_pair(QStringLiteral("formula"),
QStringLiteral("K%total-%f-%F-%M-%LM-%c%l-%prefix-%{zone}-%{site}-%sequ_1")),
std::make_pair(QStringLiteral("label"), QStringLiteral("OLD"))}) {
QDomElement e = document.createElement(QStringLiteral("elementInformation"));
e.setAttribute(QStringLiteral("name"), info.first);
e.setAttribute(QStringLiteral("show"), QStringLiteral("1"));
e.appendChild(document.createTextNode(info.second));
informations.appendChild(e);
}
QDomElement conductor = diagram.firstChildElement(QStringLiteral("conductors"))
.firstChildElement(QStringLiteral("conductor"));
QVERIFY(!conductor.isNull());
conductor.setAttribute(QStringLiteral("formula"), QStringLiteral("W%total-%id-%wf-%{zone}-%sequ_1"));
conductor.setAttribute(QStringLiteral("function"), QStringLiteral("N"));
conductor.setAttribute(QStringLiteral("num"), QStringLiteral("OLD"));
sequence(conductor, QStringLiteral("3"));
}
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(document.toByteArray());
file.close();
compareBothWays(saved);
// ...and they were worked out, not left as saved
const QString info = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("element_info")).toArray())
.toJson(QJsonDocument::Compact));
const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray())
.toJson(QJsonDocument::Compact));
QVERIFY2(info.contains(QStringLiteral("K3-1-F1-P-L-")), qPrintable(info.left(400)));
QVERIFY2(info.contains(QStringLiteral("-X-Z0-S-7")), qPrintable(info.left(400)));
QVERIFY2(wires.contains(QStringLiteral("W3-3-N-Z2-3")), qPrintable(wires.left(400)));
QVERIFY(!info.contains(QStringLiteral("OLD")));
QVERIFY(!wires.contains(QStringLiteral("OLD")));
}
// An element the folio does not build, and so the conductors ending on
// it, are left out the same way.
void unbuiltElementIsLeftOut()
{
const QString saved = m_dir.filePath(QStringLiteral("unbuilt.qet"));
QDomDocument document = resaved(saved);
QDomElement conductor = document.elementsByTagName(QStringLiteral("conductor")).at(0).toElement();
QVERIFY(!conductor.isNull());
const QString uuid = conductor.attribute(QStringLiteral("element1"));
const QDomNodeList elements = document.elementsByTagName(QStringLiteral("element"));
bool found = false;
for (int i = 0 ; i < elements.size() ; ++i) {
QDomElement e = elements.at(i).toElement();
if (e.attribute(QStringLiteral("uuid")) == uuid) {
e.setAttribute(QStringLiteral("x"), QStringLiteral("nan"));
found = true;
}
}
QVERIFY(found);
QVERIFY(write(saved, document));
compareBothWays(saved);
const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray())
.toJson(QJsonDocument::Compact));
QVERIFY(!wires.contains(conductor.attribute(QStringLiteral("uuid"))));
}
// Two elements on a folio numbering their terminals alike: which one the
// folio then refuses depends on the terminals' geometry, so the folios
// fill the database.
void clashingTerminalIdsFallBack()
{
const QString saved = m_dir.filePath(QStringLiteral("clash.qet"));
QDomDocument document = resaved(saved);
const QDomElement diagram = document.elementsByTagName(QStringLiteral("diagram")).at(0).toElement();
QDomElement first = diagram.firstChildElement(QStringLiteral("elements")).firstChildElement(QStringLiteral("element"));
QDomElement second = first.nextSiblingElement(QStringLiteral("element"));
const QString id = first.firstChildElement(QStringLiteral("terminals"))
.firstChildElement(QStringLiteral("terminal")).attribute(QStringLiteral("id"));
QDomElement terminal = second.firstChildElement(QStringLiteral("terminals"))
.firstChildElement(QStringLiteral("terminal"));
QVERIFY(!id.isEmpty() && !terminal.isNull());
terminal.setAttribute(QStringLiteral("id"), id);
QVERIFY(write(saved, document));
QString how;
dump(saved, false, &how);
QCOMPARE(how, QStringLiteral("Project database filled from the folios: "
"two elements on a folio number their terminals alike"));
}
// m_000.qet saved once, as a document to change
QDomDocument resavedWithLinks(const QString &saved)
{
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/m_000.qet"), saved});
QFile file(saved);
QDomDocument document;
if (file.open(QIODevice::ReadOnly))
document.setContent(&file);
return document;
}
// A coil and its contacts are in the link table, from each side, with
// both fills: m_000.qet's coil b02216df and its contact 998190fa.
void linksAreListed()
{
const QString saved = m_dir.filePath(QStringLiteral("links.qet"));
QDomDocument document = resavedWithLinks(saved);
const QString coil = QStringLiteral("{b02216df-0851-4732-893b-901fea80703e}"),
contact = QStringLiteral("{998190fa-5b4c-4be3-bc67-8a828eaab2b4}");
//The coil puts this contact in its contact group 1.
bool grouped = false;
const QDomNodeList elements = document.elementsByTagName(QStringLiteral("element"));
for (int i = 0 ; i < elements.size() ; ++i) {
const QDomElement e = elements.at(i).toElement();
if (e.attribute(QStringLiteral("uuid")) != coil) continue;
for (QDomElement l = e.firstChildElement(QStringLiteral("links_uuids")).firstChildElement(QStringLiteral("link_uuid")) ;
!l.isNull() ; l = l.nextSiblingElement(QStringLiteral("link_uuid"))) {
if (l.attribute(QStringLiteral("uuid")) == contact) {
l.setAttribute(QStringLiteral("group_index"), 1);
grouped = true;
}
}
}
QVERIFY(grouped);
QVERIFY(write(saved, document));
compareBothWays(saved);
bool coil_side = false, contact_side = false;
for (const QJsonValue &row : m_last.value(QStringLiteral("link")).toArray()) {
const QJsonObject link = QJsonDocument::fromJson(row.toString().toUtf8()).object();
const QString from = link.value(QStringLiteral("element_uuid")).toString(),
to = link.value(QStringLiteral("linked_uuid")).toString();
coil_side |= from == coil && to == contact
&& link.value(QStringLiteral("group_index")).toVariant().toInt() == 1;
contact_side |= from == contact && to == coil;
}
QVERIFY2(coil_side, "the coil's link to its contact, in group 1, is missing");
QVERIFY2(contact_side, "the contact's link to its coil is missing");
}
// A link only one side lists: the folios decide what it becomes.
void oneSidedLinkFallsBack()
{
const QString saved = m_dir.filePath(QStringLiteral("onesided.qet"));
QDomDocument document = resavedWithLinks(saved);
const QDomNodeList links = document.elementsByTagName(QStringLiteral("link_uuid"));
QVERIFY(links.size() > 0);
QDomNode link = links.at(0);
link.parentNode().removeChild(link);
QVERIFY(write(saved, document));
QString how;
dump(saved, false, &how);
QCOMPARE(how, QStringLiteral("Project database filled from the folios: "
"a link is not one the folios would make as saved"));
}
// After a folio is added and another removed, the folio tables hold
// what a fresh open of the saved project gives: every folio's position
// and folio number, and no row for the removed folio. Positions only
// changed when folios were reordered, and the removed folio's
// diagram_info row stayed.
void folioAddedAndRemoved()
{
const QString saved = m_dir.filePath(QStringLiteral("folios.qet"));
const QString edited = m_dir.filePath(QStringLiteral("folios-edited.qet"));
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved});
QVERIFY(QFile::exists(saved));
QFile js(m_dir.filePath(QStringLiteral("folios.js")));
QVERIFY(js.open(QIODevice::WriteOnly));
js.write(QStringLiteral(
"qet.log('ADDED ' + qet.insertFolio(0));\n"
"qet.log('REMOVED ' + qet.removeFolio(2));\n"
"var out = {};\n"
"['diagram', 'diagram_info'].forEach(function (t) { out[t] = qet.query('SELECT * FROM ' + t); });\n"
"qet.log('DUMP ' + JSON.stringify(out));\n"
"qet.log('SAVED ' + qet.save('%1'));\n").arg(edited).toUtf8());
js.close();
const QString log = run({QStringLiteral("--run"), js.fileName(), saved});
QVERIFY2(log.contains(QStringLiteral("ADDED 0")) && log.contains(QStringLiteral("REMOVED true"))
&& log.contains(QStringLiteral("SAVED true")), qPrintable(log.right(400)));
QString unused;
const QJsonObject after_edits = tablesIn(log, &unused);
const QJsonObject reopened = dump(edited, false, &unused);
for (const QString &table : {QStringLiteral("diagram"), QStringLiteral("diagram_info")}) {
const QJsonArray a = after_edits.value(table).toArray(), b = reopened.value(table).toArray();
QVERIFY2(!a.isEmpty(), qPrintable(table));
QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows after the edits, %3 on reopening, or different")
.arg(table).arg(a.size()).arg(b.size())));
}
}
// After an element is moved several columns, its folio cell in the
// element table is the one a fresh open of the saved project gives.
// It used to stay where the element was added.
void movedElementCell()
{
const QString saved = m_dir.filePath(QStringLiteral("moved.qet"));
const QString edited = m_dir.filePath(QStringLiteral("moved-edited.qet"));
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved});
QVERIFY(QFile::exists(saved));
QFile js(m_dir.filePath(QStringLiteral("moved.js")));
QVERIFY(js.open(QIODevice::WriteOnly));
js.write(QStringLiteral(
"var e = qet.elementUuids(0)[0];\n"
"var before = qet.query(\"SELECT pos FROM element WHERE uuid = '\" + e + \"'\")[0].pos;\n"
"qet.log('MOVED ' + qet.moveElement(0, e, 300, 0));\n"
"var after = qet.query(\"SELECT pos FROM element WHERE uuid = '\" + e + \"'\")[0].pos;\n"
"qet.log('CELL ' + before + ' ' + after);\n"
"qet.log('DUMP ' + JSON.stringify({element: qet.query('SELECT * FROM element')}));\n"
"qet.log('SAVED ' + qet.save('%1'));\n").arg(edited).toUtf8());
js.close();
const QString log = run({QStringLiteral("--run"), js.fileName(), saved});
QVERIFY2(log.contains(QStringLiteral("MOVED true")) && log.contains(QStringLiteral("SAVED true")),
qPrintable(log.right(400)));
QString unused;
const QJsonArray a = tablesIn(log, &unused).value(QStringLiteral("element")).toArray();
const QJsonArray b = dump(edited, false, &unused).value(QStringLiteral("element")).toArray();
QVERIFY(!a.isEmpty());
QVERIFY2(a == b, qPrintable(log.mid(log.indexOf(QStringLiteral("CELL")), 40)));
}
// After every conductor on a folio is deleted, the terminal table
// holds what a fresh open of the saved project gives: a terminal is
// listed while a conductor ends on it. The ends of a deleted conductor
// used to stay.
void deletedWireTerminals()
{
const QString saved = m_dir.filePath(QStringLiteral("unwired.qet"));
const QString edited = m_dir.filePath(QStringLiteral("unwired-edited.qet"));
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved});
QVERIFY(QFile::exists(saved));
QFile js(m_dir.filePath(QStringLiteral("unwired.js")));
QVERIFY(js.open(QIODevice::WriteOnly));
js.write(QStringLiteral(
"var n = 0;\n"
"qet.conductorUuids(0).forEach(function (c) {\n"
" var end = qet.conductorEnds(0, c)[0];\n"
" if (!end) return;\n"
" var m = end.match(/^(\\{[^}]+\\}) terminal (\\d+)$/);\n"
" if (m && qet.deleteConductor(0, m[1], parseInt(m[2]))) ++n;\n"
"});\n"
"qet.log('DELETED ' + n);\n"
"qet.log('DUMP ' + JSON.stringify({terminal: qet.query('SELECT * FROM terminal')}));\n"
"qet.log('SAVED ' + qet.save('%1'));\n").arg(edited).toUtf8());
js.close();
const QString log = run({QStringLiteral("--run"), js.fileName(), saved});
QVERIFY2(!log.contains(QStringLiteral("DELETED 0")) && log.contains(QStringLiteral("SAVED true")),
qPrintable(log.right(400)));
QString unused;
const QJsonArray a = tablesIn(log, &unused).value(QStringLiteral("terminal")).toArray();
const QJsonArray b = dump(edited, false, &unused).value(QStringLiteral("terminal")).toArray();
QVERIFY2(a == b, qPrintable(QStringLiteral("%1 terminal rows after the edits, %2 on reopening")
.arg(a.size()).arg(b.size())));
}
// A file whose items carry no saved uuid is filled from the folios,
// which work those uuids out as they are built, and says why.
void olderFileFallsBack()
{
QString how;
dump(QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), false, &how);
QCOMPARE(how, QStringLiteral("Project database filled from the folios: a folio has no saved uuid"));
}
};
QTEST_APPLESS_MAIN(tst_databasefromdocument)
#include "tst_databasefromdocument.moc"