Merge master into feat/changed-on-disk-check

Conflicts: QETProject::write() now checks for a change on disk before it
keeps the previous version; both new tests kept in tests/qttest/CMakeLists.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
This commit is contained in:
ispyisail
2026-10-16 17:44:42 +13:00
26 changed files with 1213 additions and 70 deletions
+3
View File
@@ -940,7 +940,10 @@ set(QET_SRC_FILES
${QET_DIR}/sources/utils/qetsettings.cpp
${QET_DIR}/sources/utils/qetsettings.h
${QET_DIR}/sources/utils/qetutils.cpp
${QET_DIR}/sources/utils/previousversions.cpp
${QET_DIR}/sources/utils/previousversions.h
${QET_DIR}/sources/utils/qetutils.h
${QET_DIR}/sources/utils/syncfolder.h
${QET_DIR}/sources/materiallist/materialentrydialog.cpp
${QET_DIR}/sources/materiallist/materialentrydialog.h
+56 -12
View File
@@ -28,6 +28,7 @@
#include "dxfexport.h"
#include "exportproperties.h"
#include "pdf_links.h"
#include "container/qetcontainer.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/diagramimageitem.h"
#include "qetgraphicsitem/element.h"
@@ -85,6 +86,7 @@ const QHash<QString, QString> &exportFlags()
{"--info", "info"},
{"--check-elements", "check"},
{"--resave", "resave"},
{"--recover", "recover"},
{"--set-titleblock", "settb"},
};
return flags;
@@ -870,6 +872,42 @@ int exportLinks(QETProject &project, const QString &output)
/// Round-trip: load the project and write its XML back out, so an external
/// diff can reveal markup QET silently normalises (tolerated-but-invalid XML).
/// What can be read of the damaged .qetz @p input, written to the .qet
/// @p output, and what was left out (QetContainer::recover()).
int recoverProject(const QString &input, const QString &output)
{
QFile file(input);
if (!file.open(QIODevice::ReadOnly)) {
err << "Cannot read '" << input << "': " << file.errorString() << "\n";
return 2;
}
const QByteArray content = file.readAll();
if (!QetContainer::isZip(content)) {
err << "'" << input << "' is not a .qetz project; a .qet opens as it is, or can be fixed in a text editor\n";
return 2;
}
QDomDocument document;
QStringList lost;
QString error;
if (!QetContainer::recover(content, &document, &lost, &error)) {
err << "Nothing can be recovered from '" << input << "': " << error << "\n";
return 1;
}
if (!QET::writeXmlFile(document, output, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
out << "Recovered " << input << " -> " << output << "\n";
if (lost.isEmpty()) {
out << "Nothing was left out.\n";
} else {
out << "Left out:\n";
for (const QString &item : std::as_const(lost))
out << " " << item << "\n";
}
return 0;
}
int resaveProject(QETProject &project, const QString &output)
{
if (QETProject::isContainerPath(output)) {
@@ -885,14 +923,13 @@ int resaveProject(QETProject &project, const QString &output)
//what building them would write (QETProject::toXml())
QHash<int, QByteArray> kept_folios;
const QDomDocument doc = project.toXml(&kept_folios);
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
//Through a temporary file, as the GUI saves: a run stopped
//part-way leaves the previous file
QString error;
if (!QET::writeXmlText(QETProject::spliceKeptFolios(doc.toString(4), kept_folios), output, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
QTextStream fout(&file);
fout << QETProject::spliceKeptFolios(doc.toString(4), kept_folios);
file.close();
out << "Re-saved project -> " << output << "\n";
return 0;
}
@@ -980,14 +1017,11 @@ int setTitleBlock(QETProject &project, const QString &output,
//is what building them would write (QETProject::toXml())
QHash<int, QByteArray> kept_folios;
const QDomDocument doc = project.toXml(&kept_folios);
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
QString error;
if (!QET::writeXmlText(QETProject::spliceKeptFolios(doc.toString(4), kept_folios), output, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
QTextStream fout(&file);
fout << QETProject::spliceKeptFolios(doc.toString(4), kept_folios);
file.close();
}
out << "Stamped " << fields.size() << " field(s) on "
<< folios << " folio(s) -> " << output << "\n";
@@ -1042,6 +1076,16 @@ int run(const QStringList &args)
return checkElements(rest.at(0));
}
// --recover reads what it can of a damaged .qetz, which a project
// cannot be opened from.
if (format == "recover") {
if (rest.size() < 2) {
err << "Usage: qelectrotech --recover <damaged.qetz> <output.qet>\n";
return 2;
}
return recoverProject(rest.at(0), rest.at(1));
}
const QString input = rest.value(0);
if (input.isEmpty()) {
err << "Usage: qelectrotech " << flag << " <project.qet> <output>\n";
+1
View File
@@ -55,6 +55,7 @@ namespace CLIExport {
qelectrotech --info <project.qet> [output.json]
qelectrotech --check-elements <element.elmt | directory>
qelectrotech --resave <project.qet> <output.qet>
qelectrotech --recover <damaged.qetz> <output.qet>
qelectrotech --set-titleblock <project.qet> <output.qet> key=value...
PDF: one multi-page document (one diagram per page).
+63 -16
View File
@@ -184,11 +184,37 @@ bool QetContainer::readDocument(const QByteArray &content, QDomDocument *documen
&& join(entries, document, error, nullptr, pictures, format);
}
/**
@brief QetContainer::recover
What can be read of the .qetz @p content, a damaged one, into @p project:
a folio file, a picture or an entry that cannot be read is left out,
symbols and wires that do not match the database lose what it held, and
a database that cannot be read loses it all (wires, whose ends it holds,
go too). Each thing left out is said in @p lost.
@return false, with @p error, when nothing can be read: not a zip, no
project.xml, or a later format
*/
bool QetContainer::recover(const QByteArray &content, QDomDocument *project, QStringList *lost, QString *error)
{
QStringList none;
if (!lost) lost = &none;
QList<QetZip::Entry> entries;
return QetZip::fromBytes(content, &entries, error, lost)
&& join(entries, project, error, nullptr, nullptr, nullptr, lost);
}
QString QetContainer::pictureFile(const QDomElement &image)
{
return image.attribute(ImageFileAttribute);
}
void QetContainer::inlinePicture(QDomElement image, const QByteArray &bytes)
{
image.removeAttribute(ImageFileAttribute);
image.insertBefore(image.ownerDocument().createTextNode(QString::fromLatin1(bytes.toBase64())),
image.firstChild());
}
/**
@brief QetContainer::ownUuids
@return true if every folio, symbol and wire of @p project has a uuid
@@ -421,23 +447,35 @@ QList<QetZip::Entry> QetContainer::split(const QDomDocument &project, const QStr
for a later one this may read (its min-reader allows it).
*/
bool QetContainer::join(const QList<QetZip::Entry> &entries, QDomDocument *project, QString *error,
QStringList *folio_files, Pictures *pictures, int *format)
QStringList *folio_files, Pictures *pictures, int *format, QStringList *lost)
{
//Recovering a damaged project (recover()): what is missing or
//damaged is noted in lost and left out, instead of refusing it all
const auto noted = [error, lost](const QString &problem) {
if (lost) {
lost->append(problem);
return true;
}
setError(error, problem);
return false;
};
QHash<QString, QByteArray> files;
for (const QetZip::Entry &entry : entries)
files.insert(entry.name, entry.data);
const QByteArray mimetype = files.value(QStringLiteral("mimetype"));
if (mimetype != QByteArray(MimeType) && mimetype != QByteArray(OldMimeType)) {
setError(error, QStringLiteral("not a QElectroTech project (.qetz)"));
if (mimetype != QByteArray(MimeType) && mimetype != QByteArray(OldMimeType)
&& !noted(QStringLiteral("not a QElectroTech project (.qetz)")))
return false;
}
QDomDocument manifest;
if (!parse(files.value(QStringLiteral("manifest.xml")), &manifest, error))
QString manifest_error;
const bool has_manifest = parse(files.value(QStringLiteral("manifest.xml")), &manifest, &manifest_error);
if (!has_manifest && !noted(manifest_error))
return false;
bool ok = false;
const int min_reader = manifest.documentElement().attribute(QStringLiteral("min-reader")).toInt(&ok);
if (!ok || min_reader > Format) {
//A later format is not damage: refused all the same
if (has_manifest && (!ok || min_reader > Format)) {
setError(error, QStringLiteral("made by a later QElectroTech (format %1, this one reads up to %2)")
.arg(manifest.documentElement().attribute(QStringLiteral("format")))
.arg(Format));
@@ -460,29 +498,33 @@ bool QetContainer::join(const QList<QetZip::Entry> &entries, QDomDocument *proje
&& file.startsWith(QLatin1String("folios/")))
folio_files->append(file);
if (!files.contains(file)) {
setError(error, QStringLiteral("part \"%1\" is missing").arg(file));
return false;
if (!noted(QStringLiteral("part \"%1\" is missing").arg(file))) return false;
part.parentNode().removeChild(part);
continue;
}
QDomDocument document;
QString part_error;
if (!parse(files.value(file), &document, &part_error)) {
setError(error, QStringLiteral("%1: %2").arg(file, part_error));
return false;
if (!noted(QStringLiteral("%1: %2").arg(file, part_error))) return false;
part.parentNode().removeChild(part);
continue;
}
part.parentNode().replaceChild(project->importNode(document.documentElement(), true), part);
}
//Pictures back to base64, as the image's first child, or into
//pictures
const QDomNodeList images = project->elementsByTagName(QStringLiteral("image"));
for (int i = 0 ; i < images.size() ; ++i)
const QDomNodeList image_list = project->elementsByTagName(QStringLiteral("image"));
QList<QDomElement> images;
for (int i = 0 ; i < image_list.size() ; ++i) images << image_list.at(i).toElement();
for (QDomElement image : images)
{
QDomElement image = images.at(i).toElement();
if (!image.hasAttribute(ImageFileAttribute)) continue;
const QString file = image.attribute(ImageFileAttribute);
if (!files.contains(file)) {
setError(error, QStringLiteral("picture \"%1\" is missing").arg(file));
return false;
if (!noted(QStringLiteral("picture \"%1\" is missing").arg(file))) return false;
image.parentNode().removeChild(image);
continue;
}
if (pictures) {
pictures->insert(file, files.value(file));
@@ -496,7 +538,12 @@ bool QetContainer::join(const QList<QetZip::Entry> &entries, QDomDocument *proje
//The engineering data back from project.sqlite (format 2 on)
if (files.contains(QStringLiteral("project.sqlite"))
&& !QetContainerDb::restore(project->documentElement(),
files.value(QStringLiteral("project.sqlite")), error))
files.value(QStringLiteral("project.sqlite")), error, lost))
return false;
if (lost) {
if (!files.contains(QStringLiteral("project.sqlite")))
lost->append(QStringLiteral("project.sqlite is missing"));
QetContainerDb::dropLeftovers(project->documentElement(), lost);
}
return true;
}
+8 -1
View File
@@ -77,9 +77,16 @@ namespace QetContainer
const QHash<QString, QByteArray> &kept_from = QHash<QString, QByteArray>());
bool join(const QList<QetZip::Entry> &entries, QDomDocument *project,
QString *error = nullptr, QStringList *folio_files = nullptr,
Pictures *pictures = nullptr, int *format = nullptr);
Pictures *pictures = nullptr, int *format = nullptr,
QStringList *lost = nullptr);
/// What can be read of a damaged .qetz, and in @p lost what could not
bool recover(const QByteArray &content, QDomDocument *project, QStringList *lost,
QString *error = nullptr);
/// The file join() left the picture of @p image in, or an empty string
QString pictureFile(const QDomElement &image);
/// @p image, which names its picture's file (pictureFile()), given
/// the picture itself, @p bytes, as a document read whole has it
void inlinePicture(QDomElement image, const QByteArray &bytes);
/// Take what QElectroTech computes out of a folio (format 4)
void dropComputed(QDomElement diagram, bool terminal_lists);
+126 -34
View File
@@ -570,6 +570,32 @@ bool plainTables(sqlite3 *db)
return ok && run(QStringLiteral("VACUUM"));
}
//What SQLite advises for a database read from a file
//(https://www.sqlite.org/security.html): nothing in its schema runs, no
//trigger or view either, the defensive mode, no value longer than any
//project holds, and the size of each cell checked as its page is read.
//The database @p schema of @p db ("main", or an attached one) is then
//checked whole, so that damage is said as such before anything reads
//it, instead of showing as a missing row or not at all.
bool readAsUntrusted(Database &db, const QString &schema, QString *error)
{
sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_DEFENSIVE, 1, nullptr);
sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_ENABLE_TRIGGER, 0, nullptr);
#ifdef SQLITE_DBCONFIG_ENABLE_VIEW
sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_ENABLE_VIEW, 0, nullptr);
#endif
db.exec(QStringLiteral("PRAGMA trusted_schema = OFF"));
db.exec(QStringLiteral("PRAGMA cell_size_check = ON"));
sqlite3_limit(db.m_db, SQLITE_LIMIT_LENGTH, 256 * 1024 * 1024);
Statement check(db, QStringLiteral("PRAGMA %1.quick_check(1)").arg(schema));
const QString result = check.next() ? check.value(0).toString() : db.error();
if (result != QLatin1String("ok")) {
setError(error, QStringLiteral("the project database is damaged: %1").arg(result));
return false;
}
return true;
}
//@p database opened in @p db, its tables checked to be plain tables and
//its format one this reads: a file is untrusted
bool openDatabase(Database &db, const QByteArray &database, QString *error)
@@ -587,9 +613,10 @@ bool openDatabase(Database &db, const QByteArray &database, QString *error)
setError(error, QStringLiteral("the project database cannot be read"));
return false;
}
//A file is untrusted: nothing in its schema may run, and every name
//read must be a plain table.
db.exec(QStringLiteral("PRAGMA trusted_schema = OFF"));
//A file is untrusted (readAsUntrusted()), and every name read must
//be a plain table.
if (!readAsUntrusted(db, QStringLiteral("main"), error))
return false;
for (const QString &table : DocTables) {
Statement kind(db, QStringLiteral("SELECT type FROM sqlite_master WHERE name = ?"));
kind.bind({table});
@@ -749,8 +776,22 @@ struct UsedRows
}
};
//The engineering data of the folio @p d back from @p db
bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error)
//A database that does not match its folios: refused, or, with @p lost
//(a damaged project recovered, QetContainer::recover()), @p problem
//noted and gone past. @return true to go on
bool noted(QString *error, QStringList *lost, const QString &problem)
{
if (lost) {
lost->append(problem);
return true;
}
setError(error, problem);
return false;
}
//The engineering data of the folio @p d back from @p db; with @p lost,
//what does not match is noted and left out (noted())
bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error, QStringList *lost)
{
QDomDocument document = d.ownerDocument();
auto entry = [&document](const QVariant &name_attr, const QString &name, const QVariant &value,
@@ -759,10 +800,11 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error)
};
const QString id = keyOf(d, FolioAttribute);
if (id.isEmpty()) return true;
if (!used.use(used.folios, id, QStringLiteral("folio"), error)) return false;
QString problem;
if (!used.use(used.folios, id, QStringLiteral("folio"), &problem)) return noted(error, lost, problem);
Statement f(db, QStringLiteral("SELECT %1, props_attrs FROM folio WHERE uuid = ?").arg(quoted(FolioData)));
f.bind({id});
if (!f.next()) { setError(error, QStringLiteral("folio %1 is not in the database").arg(id)); return false; }
if (!f.next()) return noted(error, lost, QStringLiteral("folio %1 is not in the database").arg(id));
QVariantList values;
for (int i = 0 ; i < FolioData.size() + 1 ; ++i) values << f.value(i);
put(d, FolioData, values, 0);
@@ -777,24 +819,30 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error)
props.appendChild(entry(p.value(0), QStringLiteral("name"), p.value(1), p.value(2),
QStringLiteral("property"), p.value(3)));
restoreWhitespace(props, values.at(FolioData.size()));
if (!replaceMarker(d, props)) {
setError(error, QStringLiteral("folio %1's properties have no place").arg(id));
if (!replaceMarker(d, props)
&& !noted(error, lost, QStringLiteral("folio %1's properties have no place").arg(id)))
return false;
}
}
for (const QDomElement &list : children(d, QStringLiteral("elements"))) {
for (QDomElement e : children(list, QStringLiteral("element"))) {
const QString key = keyOf(e, KeyAttribute);
if (key.isEmpty()) continue;
if (!used.use(used.elements, key, QStringLiteral("symbol"), error)) return false;
if (!used.use(used.elements, key, QStringLiteral("symbol"), &problem)) {
if (!noted(error, lost, problem)) return false;
continue;
}
Statement s(db, QStringLiteral("SELECT %1, info_attrs, links_attrs, folio_uuid FROM element WHERE uuid = ?")
.arg(quoted(ElementData)));
s.bind({key});
if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; }
if (!s.next()) {
if (!noted(error, lost, QStringLiteral("symbol %1 is not in the database").arg(key))) return false;
continue;
}
if (s.value(ElementData.size() + 2).toString() != id) {
setError(error, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id));
return false;
if (!noted(error, lost, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id)))
return false;
continue;
}
QVariantList v;
for (int i = 0 ; i < ElementData.size() + 2 ; ++i) v << s.value(i);
@@ -821,25 +869,31 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error)
QStringLiteral("link_uuid")));
restoreWhitespace(links, v.at(n + 1));
}
if ((!info.isNull() && !replaceMarker(e, info))
|| (!links.isNull() && !replaceMarker(e, links))) {
setError(error, QStringLiteral("symbol %1's information has no place").arg(key));
if (((!info.isNull() && !replaceMarker(e, info))
|| (!links.isNull() && !replaceMarker(e, links)))
&& !noted(error, lost, QStringLiteral("symbol %1's information has no place").arg(key)))
return false;
}
}
}
for (const QDomElement &list : children(d, QStringLiteral("conductors"))) {
for (QDomElement c : children(list, QStringLiteral("conductor"))) {
const QString key = keyOf(c, KeyAttribute);
if (key.isEmpty()) continue;
if (!used.use(used.conductors, key, QStringLiteral("wire"), error)) return false;
if (!used.use(used.conductors, key, QStringLiteral("wire"), &problem)) {
if (!noted(error, lost, problem)) return false;
continue;
}
Statement s(db, QStringLiteral("SELECT %1, folio_uuid FROM conductor WHERE uuid = ?")
.arg(quoted(ConductorData)));
s.bind({key});
if (!s.next()) { setError(error, QStringLiteral("wire %1 is not in the database").arg(key)); return false; }
if (!s.next()) {
if (!noted(error, lost, QStringLiteral("wire %1 is not in the database").arg(key))) return false;
continue;
}
if (s.value(ConductorData.size()).toString() != id) {
setError(error, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id));
return false;
if (!noted(error, lost, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id)))
return false;
continue;
}
QVariantList v;
for (int i = 0 ; i < ConductorData.size() ; ++i) v << s.value(i);
@@ -902,7 +956,8 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error, const QB
setError(error, QStringLiteral("cannot read the database the kept folios come from"));
return {};
}
db.exec(QStringLiteral("PRAGMA trusted_schema = OFF"));
if (!readAsUntrusted(db, QStringLiteral("old"), error))
return {};
for (const QString &table : DocTables) {
Statement kind(db, QStringLiteral("SELECT type FROM old.sqlite_master WHERE name = ?"));
kind.bind({table});
@@ -1135,12 +1190,14 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error, const QB
Put the engineering data in @p database back into @p project (the
<project> element, its folios joined back already).
*/
bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QString *error)
bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QString *error,
QStringList *lost)
{
QDomDocument document = project.ownerDocument();
Database db;
if (!openDatabase(db, database, error) || !readyToRead(db, project, error))
return false;
QString problem;
if (!openDatabase(db, database, &problem) || !readyToRead(db, project, &problem))
return noted(error, lost, problem);
//Project properties
QHash<QString, QVariant> meta;
@@ -1156,10 +1213,9 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS
props.appendChild(entryElement(document, s.value(0), QStringLiteral("name"), s.value(1), s.value(2),
QStringLiteral("property"), s.value(3)));
restoreWhitespace(props, meta.value(QStringLiteral("props_attrs")));
if (!replaceMarker(project, props)) {
setError(error, QStringLiteral("the project's properties have no place"));
if (!replaceMarker(project, props)
&& !noted(error, lost, QStringLiteral("the project's properties have no place")))
return false;
}
}
if (meta.contains(QStringLiteral("terminal_strips"))) {
@@ -1176,17 +1232,53 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS
read(QStringLiteral("SELECT * FROM strip_terminal ORDER BY strip_uuid, pos, level"), 4, &rows.terminals);
read(QStringLiteral("SELECT * FROM strip_bridge ORDER BY ord"), 4, &rows.bridges);
read(QStringLiteral("SELECT * FROM strip_bridge_terminal ORDER BY bridge_uuid, ord"), 3, &rows.bridge_terminals);
if (!replaceMarker(project, stripsXml(document, rows))) {
setError(error, QStringLiteral("the terminal strips have no place"));
if (!replaceMarker(project, stripsXml(document, rows))
&& !noted(error, lost, QStringLiteral("the terminal strips have no place")))
return false;
}
}
UsedRows used;
for (QDomElement d : children(project, QStringLiteral("diagram")))
if (!restoreDiagram(db, d, used, error))
if (!restoreDiagram(db, d, used, error, lost))
return false;
return noRowLeft(db, used, error);
if (!noRowLeft(db, used, &problem))
return noted(error, lost, problem);
return true;
}
/**
@brief QetContainerDb::dropLeftovers
After restore() of a damaged project (with lost): take out of
@p project what only the database could have filled -- the markers of
blocks it held, the keys of rows -- and the wires whose ends it held,
which cannot be drawn without them. Each kind left out is said in @p lost.
*/
void QetContainerDb::dropLeftovers(QDomElement project, QStringList *lost)
{
QList<QDomElement> markers, wires;
QList<QDomElement> all{project};
for (int i = 0 ; i < all.size() ; ++i) {
QDomElement e = all.at(i);
for (QDomElement c = e.firstChildElement() ; !c.isNull() ; c = c.nextSiblingElement())
all << c;
if (e.tagName() == MarkerTag) markers << e;
e.removeAttribute(KeyAttribute);
e.removeAttribute(FolioAttribute);
e.removeAttribute(KeptPartAttribute);
if (e.tagName() == QLatin1String("conductor") && e.parentNode().nodeName() == QLatin1String("conductors")
//Every format names a wire's ends by terminal (older files
//by terminal only), and a .qetz holds them in the database
&& (!e.hasAttribute(QStringLiteral("terminal1")) || !e.hasAttribute(QStringLiteral("terminal2"))))
wires << e;
}
for (QDomElement m : std::as_const(markers))
m.parentNode().removeChild(m);
for (QDomElement w : std::as_const(wires))
w.parentNode().removeChild(w);
if (lost && !markers.isEmpty())
lost->append(QStringLiteral("%1 blocks of information the database held").arg(markers.size()));
if (lost && !wires.isEmpty())
lost->append(QStringLiteral("%1 wires, whose ends the database held").arg(wires.size()));
}
/**
+8 -1
View File
@@ -20,6 +20,7 @@
#include <QByteArray>
#include <QDomElement>
#include <QStringList>
/**
@brief The QetContainerDb namespace
@@ -41,7 +42,13 @@ namespace QetContainerDb
{
QByteArray extract(QDomElement project, QString *error = nullptr,
const QByteArray &kept_from = QByteArray());
bool restore(QDomElement project, const QByteArray &database, QString *error = nullptr);
/// With @p lost, a damaged project being recovered: what does not
/// match is noted there and left out, instead of refusing it all
bool restore(QDomElement project, const QByteArray &database, QString *error = nullptr,
QStringList *lost = nullptr);
/// What only the database could fill, left in @p project after a
/// restore() with lost, taken out; said in @p lost
void dropLeftovers(QDomElement project, QStringList *lost);
/// true if @p database is of the format this writes
bool currentFormat(const QByteArray &database);
}
+22 -4
View File
@@ -18,6 +18,7 @@
#include "qetzip.h"
#include "miniz/miniz.h"
#include "../utils/syncfolder.h"
#include <QFile>
#include <QSaveFile>
@@ -152,6 +153,7 @@ bool QetZip::write(const QString &path, const QList<Entry> &entries, QString *er
setError(error, file.errorString());
return false;
}
QET::syncFolderOf(path);
return true;
}
@@ -160,9 +162,12 @@ bool QetZip::write(const QString &path, const QList<Entry> &entries, QString *er
Read every file entry of the zip @p zip into @p entries, in archive
order. Folders are skipped. An encrypted entry, an unsafe or repeated
name, a method miniz cannot read, or more than MaxEntries entries or
MaxUncompressedSize bytes refuse the whole archive.
MaxUncompressedSize bytes refuse the whole archive. With @p skipped (a
damaged project recovered), an entry that cannot be read is named
there and left out instead; the size limits still refuse it all.
*/
bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QString *error)
bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QString *error,
QStringList *skipped)
{
if (!entries) return false;
entries->clear();
@@ -188,6 +193,10 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QStri
{
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
if (skipped) {
skipped->append(QStringLiteral("unreadable entry %1").arg(i));
continue;
}
setError(error, QStringLiteral("unreadable entry %1").arg(i));
mz_zip_reader_end(&zip);
return false;
@@ -197,6 +206,10 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QStri
const QString name = QString::fromUtf8(stat.m_filename);
if (stat.m_is_encrypted || !stat.m_is_supported || !safeName(name)
|| names.contains(name)) {
if (skipped) {
skipped->append(QStringLiteral("entry \"%1\" cannot be read").arg(name));
continue;
}
setError(error, QStringLiteral("entry \"%1\" cannot be read").arg(name));
mz_zip_reader_end(&zip);
return false;
@@ -211,8 +224,13 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QStri
size_t size = 0;
void *data = mz_zip_reader_extract_to_heap(&zip, i, &size, 0);
if (!data) {
setError(error, QStringLiteral("cannot unpack \"%1\": %2")
.arg(name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip)))));
const QString problem = QStringLiteral("cannot unpack \"%1\": %2")
.arg(name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip))));
if (skipped) {
skipped->append(problem);
continue;
}
setError(error, problem);
mz_zip_reader_end(&zip);
return false;
}
+3 -1
View File
@@ -21,6 +21,7 @@
#include <QByteArray>
#include <QList>
#include <QString>
#include <QStringList>
/**
@brief The QetZip namespace
@@ -49,7 +50,8 @@ namespace QetZip
bool write(const QString &path, const QList<Entry> &entries, QString *error = nullptr);
QByteArray toBytes(const QList<Entry> &entries, QString *error = nullptr);
bool read(const QString &path, QList<Entry> *entries, QString *error = nullptr);
bool fromBytes(const QByteArray &zip, QList<Entry> *entries, QString *error = nullptr);
bool fromBytes(const QByteArray &zip, QList<Entry> *entries, QString *error = nullptr,
QStringList *skipped = nullptr);
}
#endif // QETZIP_H
+11
View File
@@ -881,6 +881,17 @@ QDomElement Diagram::deferredXmlWithHeader(QDomDocument &document)
for (QDomElement child = items.firstChildElement() ; !child.isNull() ; child = child.nextSiblingElement())
if (!header_children.contains(child.tagName()))
folio.appendChild(document.importNode(child, true));
//Its pictures written in, as building it writes them. A folio read
//from a .qetz names its pictures' files instead, which the next
//.qetz written from this document would leave out: that project
//then refused to open, "picture ... is missing" (F118).
for (QDomElement image : QET::findInDomElement(folio, QStringLiteral("images"), QStringLiteral("image"))) {
const QString file = QetContainer::pictureFile(image);
if (file.isEmpty() || !m_project) continue;
const QByteArray bytes = m_project->containerPicture(file);
if (!bytes.isEmpty())
QetContainer::inlinePicture(image, bytes);
}
return folio;
}
+2
View File
@@ -19,6 +19,7 @@
#include "qetapp.h"
#include "qeticons.h"
#include "shortcutmanager.h"
#include "utils/syncfolder.h"
#include <cmath>
#include <limits>
@@ -809,6 +810,7 @@ bool QET::writeXmlText(const QString &text, const QString &filepath, QString *er
return false;
}
syncFolderOf(filepath);
return(true);
}
+125 -1
View File
@@ -16,6 +16,9 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "qetdiagrameditor.h"
#include "container/qetcontainer.h"
#include "container/qetzip.h"
#include "utils/previousversions.h"
#include "shownkinds.h"
#ifdef QET_HAS_SCRIPTING
#include "scripting/qetscripting.h"
@@ -25,6 +28,8 @@
#include "scripting/macrorecorder.h"
#include "scripting/assistantinfo.h"
#endif
#include <QDomDocument>
#include <QTemporaryDir>
#include <QCoreApplication>
#include <QToolButton>
#include <QWidgetAction>
@@ -1888,6 +1893,115 @@ bool QETDiagramEditor::closeProject(QETProject *project)
return(true);
}
/**
@brief QETDiagramEditor::offerToRecover
@p filepath could not be read (@p reason): say why, and offer its newest
previous version (PreviousVersions) and, for a .qetz, what can be read
of it (QetContainer::recover()). Either opens as a new project,
"<name> (recovered)"; @p filepath is never moved or written.
@return true if a project was opened
*/
bool QETDiagramEditor::offerToRecover(const QString &filepath, const QString &reason)
{
const QFileInfo info(filepath);
QFile file(filepath);
const bool zip = file.open(QIODevice::ReadOnly) && QetContainer::isZip(file.peek(4));
file.close();
const QStringList versions = PreviousVersions::kept(filepath);
QMessageBox box(QMessageBox::Warning, tr("Cannot open %1").arg(info.fileName()),
reason.isEmpty()
? tr("The file %1 cannot be read as a QElectroTech project.").arg(info.fileName())
: tr("The project file is damaged:\n%1").arg(reason),
QMessageBox::Cancel, this);
QAbstractButton *previous = nullptr, *recover = nullptr;
if (!versions.isEmpty()) {
const QDateTime saved = QFileInfo(versions.first()).lastModified();
previous = box.addButton(tr("Open the version saved %1")
.arg(QLocale().toString(saved, QLocale::ShortFormat)),
QMessageBox::AcceptRole);
}
if (zip)
recover = box.addButton(tr("Recover what can be read"), QMessageBox::AcceptRole);
if (previous || recover)
box.setInformativeText(tr("Either opens as a new project, \"%1\"; %2 is left as it is.")
.arg(recoveredPath(filepath, info.suffix()).section(QLatin1Char('/'), -1),
info.fileName()));
box.exec();
if (previous && box.clickedButton() == previous)
return openAsRecovered(versions.first(), filepath);
if (recover && box.clickedButton() == recover) {
file.open(QIODevice::ReadOnly);
QDomDocument document;
QStringList lost;
QString error;
if (!QetContainer::recover(file.readAll(), &document, &lost, &error)) {
QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(info.fileName()),
tr("Nothing can be recovered from it:\n%1").arg(error));
return false;
}
//Opened from a .qetz written of it, as the project it replaces
QTemporaryDir dir;
const QString read = dir.filePath(info.completeBaseName() + QStringLiteral(".qetz"));
const QList<QetZip::Entry> parts = QetContainer::split(document, QString(), &error);
if (!dir.isValid() || parts.isEmpty() || !QetZip::write(read, parts, &error)
|| !openAsRecovered(read, filepath)) {
if (!error.isEmpty())
QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(info.fileName()), error);
return false;
}
if (!lost.isEmpty()) {
QMessageBox done(QMessageBox::Information, tr("Project recovered"),
tr("What could be read of %1 is open as a new project. "
"Some parts could not be read and were left out: see the details.")
.arg(info.fileName()),
QMessageBox::Ok, this);
done.setDetailedText(lost.join(QLatin1Char('\n')));
done.exec();
}
return true;
}
return false;
}
/**
@brief QETDiagramEditor::recoveredPath
@return where a project recovered from @p filepath is saved: beside it,
"<name> (recovered).<suffix>", numbered if that is taken
*/
QString QETDiagramEditor::recoveredPath(const QString &filepath, const QString &suffix)
{
const QFileInfo info(filepath);
QString path = info.absoluteDir().filePath(tr("%1 (recovered)").arg(info.completeBaseName())
+ QLatin1Char('.') + suffix);
for (int n = 2 ; QFileInfo::exists(path) ; ++n)
path = info.absoluteDir().filePath(tr("%1 (recovered %2)").arg(info.completeBaseName()).arg(n)
+ QLatin1Char('.') + suffix);
return path;
}
/**
@brief QETDiagramEditor::openAsRecovered
Open @p source as a new project saved beside @p original, of its type
(recoveredPath()), not saved yet.
@return true if it opened
*/
bool QETDiagramEditor::openAsRecovered(const QString &source, const QString &original)
{
QETProject *project = new QETProject(source);
if (project->state() != QETProject::Ok) {
QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(QFileInfo(source).fileName()),
project->openError());
delete project;
return false;
}
project->setFilePath(recoveredPath(original, QFileInfo(original).suffix()));
project->setModified(true);
addProject(project);
return true;
}
/**
Ouvre un projet depuis un fichier et l'ajoute a cet editeur
@param filepath Chemin du projet a ouvrir
@@ -1979,7 +2093,17 @@ bool QETDiagramEditor::openAndAddProject(
QETProject *project = new QETProject(filepath);
if (project -> state() != QETProject::Ok)
{
if (interactive && project -> state() != QETProject::FileOpenDiscard)
//A damaged project: its previous version or what can be read
//of it, as a new project; the file itself is left as it is
if (interactive && project->state() == QETProject::XmlParsingFailed
&& offerToRecover(filepath, project->openError()))
{
delete project;
DialogWaiting::dropInstance();
return(true);
}
if (interactive && project -> state() != QETProject::FileOpenDiscard
&& project -> state() != QETProject::XmlParsingFailed)
{
QET::QetMessageBox::warning(
this,
+3
View File
@@ -86,6 +86,9 @@ class QETDiagramEditor : public QETMainWindow
protected:
bool event(QEvent *) override;
private:
bool offerToRecover(const QString &filepath, const QString &reason);
bool openAsRecovered(const QString &source, const QString &original);
static QString recoveredPath(const QString &filepath, const QString &suffix);
// Declared first so it is initialised before any member whose
// constructor may dispatch a Qt event calling event() (e.g. the
// QActionGroup members below trigger QObject::setParent events).
@@ -1098,6 +1098,19 @@ void DynamicElementTextItem::handlerMouseReleaseEvent(QetGraphicsHandlerItem *ha
void DynamicElementTextItem::elementInfoChanged()
{
//A folio report's label shows where its partner is
//(updateReportText()); the label in its information is only where
//the partner was when last saved, and changing any other
//information wrote that stale position back (F116)
if (m_text_from == ElementInfo && m_info_name == QLatin1String("label")
&& m_parent_element && (m_parent_element.data()->linkType() & Element::AllReport)
&& m_other_report) {
setupFormulaConnection();
updateReportText();
emit plainTextChanged();
return;
}
DiagramContext dc;
Element *element = elementUseForInfo();
if(element) {
+19
View File
@@ -48,6 +48,7 @@
#include "qetinformation.h"
#include "qetversion.h"
#include "undocommand/adddiagramcommand.h"
#include "utils/previousversions.h"
#include <QCryptographicHash>
#include <QElapsedTimer>
@@ -521,6 +522,7 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
if (!read)
{
qWarning().noquote() << "Unable to read" << fi.fileName() << ":" << read_error;
m_open_error = read_error;
m_container_pictures.clear();
m_folio_parts.clear();
if(opened_here) {
@@ -609,6 +611,16 @@ QETProject::ProjectState QETProject::state() const
return(m_state);
}
/**
@brief QETProject::openError
@return why the file could not be read, when state() is
XmlParsingFailed: "picture ... is missing", say; empty otherwise
*/
QString QETProject::openError() const
{
return m_open_error;
}
/**
@return la liste des schemas de ce projet
*/
@@ -2832,6 +2844,13 @@ QETResult QETProject::write(bool overwrite_changed)
.arg(QLocale::system().toString(when, QLocale::ShortFormat))));
}
//The file as it was goes among its previous versions first
//(Settings > General > Projects); a copy that fails does not stop
//the save
QString backup_error;
if (!PreviousVersions::keep(m_file_path, PreviousVersions::count(), &backup_error))
qWarning().noquote() << "Previous version of" << m_file_path << "not kept:" << backup_error;
QString error_message;
if (isContainerPath(m_file_path))
{
+3
View File
@@ -124,6 +124,7 @@ class QETProject : public QObject
QUuid derivedItemUuid(const QString &kind, const QString &key);
QByteArray containerPicture(const QString &file) const;
ProjectState state() const;
QString openError() const;
QList<Diagram *> diagrams() const;
//The folios whether their items are built or not
//(lazy folios); diagrams() builds them
@@ -593,6 +594,8 @@ class QETProject : public QObject
QFuture<bool> m_backup_future;
/// Crash-recovery snapshots, written in turn by writeBackup()
std::array<KAutoSaveFile, BackupGenerations> m_backup_files;
/// Why the file could not be read, see openError()
QString m_open_error;
int m_next_backup_slot = 0;
QUuid m_uuid = QUuid::createUuid();
QHash<QString, int> m_derived_uuid_keys;
@@ -26,6 +26,7 @@
#include "../../cablelist/cabletypelist.h"
#include "../../utils/qetsettings.h"
#include "../../utils/qetutils.h"
#include "../../utils/previousversions.h"
#include "../../qetmessagebox.h"
#include "../../textgrid.h"
#include "../../wiringrules.h"
@@ -190,6 +191,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool());
ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool());
ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt());
ui->m_previous_versions_sb->setValue(PreviousVersions::count());
QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " +
settings.value("diagramitemsize", "9").toString() + " (" +
@@ -417,6 +419,7 @@ void GeneralConfigurationPage::applyConf()
}
}
settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value());
PreviousVersions::setCount(ui->m_previous_versions_sb->value());
settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked());
settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked());
@@ -365,6 +365,29 @@
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="m_previous_versions_label">
<property name="text">
<string>Previous versions kept when saving a project</string>
</property>
<property name="toolTip">
<string>Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept.</string>
</property>
</widget>
</item>
<item row="7" column="2">
<widget class="QSpinBox" name="m_previous_versions_sb">
<property name="toolTip">
<string>Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept.</string>
</property>
<property name="specialValueText">
<string>None</string>
</property>
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="8" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
@@ -1534,6 +1557,7 @@
<tabstop>m_use_folio_label</tabstop>
<tabstop>m_border_0</tabstop>
<tabstop>m_autosave_sb</tabstop>
<tabstop>m_previous_versions_sb</tabstop>
<tabstop>m_common_elmt_path_cb</tabstop>
<tabstop>m_custom_elmt_path_cb</tabstop>
<tabstop>m_custom_tbt_path_cb</tabstop>
+113
View File
@@ -0,0 +1,113 @@
/*
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 "previousversions.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSettings>
namespace {
const QString CountKey = QStringLiteral("diagrameditor/previous-versions");
constexpr int DefaultCount = 5;
//What a version of @p info is named, without the extension: its base
//name and a time
QString versionPrefix(const QFileInfo &info)
{
return info.completeBaseName() + QLatin1Char(' ');
}
QByteArray digest(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(&file);
return hash.result();
}
}
int PreviousVersions::count()
{
return qMax(0, QSettings().value(CountKey, DefaultCount).toInt());
}
void PreviousVersions::setCount(int count)
{
QSettings().setValue(CountKey, qMax(0, count));
}
QString PreviousVersions::folder(const QString &file_path)
{
const QFileInfo info(file_path);
return info.absoluteDir().filePath(info.completeBaseName() + QStringLiteral("-backups"));
}
QStringList PreviousVersions::kept(const QString &file_path)
{
const QFileInfo info(file_path);
const QDir dir(folder(file_path));
//Each copy has the time its version was saved (keep()); a .qet and
//a .qetz of the same project are versions of it alike
QStringList names = dir.entryList({versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qet"),
versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qetz")},
QDir::Files, QDir::Time);
QStringList paths;
for (const QString &name : std::as_const(names))
paths << dir.filePath(name);
return paths;
}
bool PreviousVersions::keep(const QString &file_path, int count, QString *error)
{
const QFileInfo info(file_path);
if (count <= 0 || !info.isFile()) return true;
const QStringList versions = kept(file_path);
if (!versions.isEmpty() && QFileInfo(versions.first()).size() == info.size()
&& digest(versions.first()) == digest(file_path))
return true;
QDir dir(folder(file_path));
if (!dir.exists() && !QDir().mkpath(dir.path())) {
if (error) *error = QStringLiteral("cannot create the folder %1").arg(dir.path());
return false;
}
//Named by the time this version was saved, made unique if another
//was saved the same second
const QDateTime saved = info.lastModified();
const QString stem = versionPrefix(info) + saved.toString(QStringLiteral("yyyy-MM-dd HH-mm-ss"));
QString target = dir.filePath(stem + QLatin1Char('.') + info.suffix());
for (int n = 2 ; QFileInfo::exists(target) ; ++n)
target = dir.filePath(stem + QStringLiteral(" (%1).").arg(n) + info.suffix());
if (!QFile::copy(file_path, target)) {
if (error) *error = QStringLiteral("cannot copy %1 to %2").arg(file_path, target);
return false;
}
QFile copy(target);
if (copy.open(QIODevice::ReadWrite))
copy.setFileTime(saved, QFileDevice::FileModificationTime);
const QStringList all = kept(file_path);
for (int i = count ; i < all.size() ; ++i)
QFile::remove(all.at(i));
return true;
}
+49
View File
@@ -0,0 +1,49 @@
/*
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/>.
*/
#ifndef PREVIOUSVERSIONS_H
#define PREVIOUSVERSIONS_H
#include <QString>
#include <QStringList>
/**
@brief The PreviousVersions namespace
The versions of a project file kept before it is saved over, as KiCad
and AutoCAD keep theirs: a copy of the file as it was goes in a folder
beside it, "<name>-backups", named by the time that version was saved
("industrial 2026-10-16 08-30-12.qetz"), and only the newest are kept.
The file is copied, never moved: a save that fails leaves it in place.
*/
namespace PreviousVersions
{
/// How many versions to keep (Settings > General > Projects); 0 keeps none
int count();
void setCount(int count);
/// The folder the versions of @p file_path go in
QString folder(const QString &file_path);
/// The versions kept of @p file_path, newest first
QStringList kept(const QString &file_path);
/// Copy @p file_path, about to be saved over, among its versions and
/// keep only the @p count newest. Nothing is copied when the file does
/// not exist, when @p count is 0, or when the newest version kept
/// holds the same bytes. @return false, with @p error, when the copy
/// could not be made: the save goes on all the same.
bool keep(const QString &file_path, int count, QString *error = nullptr);
}
#endif // PREVIOUSVERSIONS_H
+54
View File
@@ -0,0 +1,54 @@
/*
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/>.
*/
#ifndef SYNCFOLDER_H
#define SYNCFOLDER_H
#include <QFile>
#include <QFileInfo>
#include <QString>
#ifndef Q_OS_WIN
#include <fcntl.h>
#include <unistd.h>
#endif
namespace QET {
/**
Force to disk the folder holding @p file_path, once
QSaveFile::commit() renamed a new file into it. QSaveFile syncs the
file it wrote but not the folder, and on some file systems (ext4,
XFS) a power cut soon after can then lose the rename: the previous
file comes back, or none. Does nothing on Windows, where a folder
cannot be synced this way.
*/
inline void syncFolderOf(const QString &file_path)
{
#ifndef Q_OS_WIN
const QByteArray folder = QFile::encodeName(QFileInfo(file_path).absolutePath());
const int fd = ::open(folder.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0) {
::fsync(fd);
::close(fd);
}
#else
Q_UNUSED(file_path)
#endif
}
}
#endif // SYNCFOLDER_H
+35
View File
@@ -931,6 +931,30 @@ if(QET_HAS_SCRIPTING)
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A folio report keeps showing where its partner is when another of its
# information changes (F116), on examples/iso_sfc_example.qet.
add_executable(
tst_reporttextafterinfo
tst_reporttextafterinfo.cpp)
add_test(NAME tst_reporttextafterinfo COMMAND tst_reporttextafterinfo)
add_dependencies(tst_reporttextafterinfo qelectrotech)
target_link_libraries(tst_reporttextafterinfo PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_reporttextafterinfo PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A .qetz folio whose title only changed keeps its pictures when saved
# with folios built when needed (F118), on examples/grafcet.qet.
add_executable(
tst_qetzheaderpictures
tst_qetzheaderpictures.cpp)
add_test(NAME tst_qetzheaderpictures COMMAND tst_qetzheaderpictures)
add_dependencies(tst_qetzheaderpictures qelectrotech)
target_link_libraries(tst_qetzheaderpictures PRIVATE Qt::Test)
target_compile_definitions(tst_qetzheaderpictures PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# Saving refuses a project file another user or program changed since it was
# opened (QETProject::changedOnDisk()); QET_OVERWRITE_CHANGED=1 overrides.
add_executable(
@@ -1276,6 +1300,17 @@ if(Qt6_VERSION VERSION_LESS 6.5.0)
endforeach()
endif()
# The versions of a project kept before it is saved over: the newest
# kept, same bytes not kept twice, a failed copy said
# (sources/utils/previousversions.*).
add_executable(
tst_previousversions
tst_previousversions.cpp
${QET_DIR}/sources/utils/previousversions.cpp)
add_test(NAME tst_previousversions COMMAND tst_previousversions)
target_include_directories(tst_previousversions PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_previousversions PRIVATE Qt::Test)
# The zip a .qetz project is stored in: round trip, same bytes twice,
# unsafe entry names refused (sources/container/qetzip.*, bundled miniz).
add_executable(
+127
View File
@@ -0,0 +1,127 @@
/*
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 <QDateTime>
#include <QFile>
#include <QTemporaryDir>
#include "utils/previousversions.h"
// The versions of a project kept before it is saved over
// (sources/utils/previousversions.*)
class tst_previousversions : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// @p path holding @p text, saved at @p when
static void write(const QString &path, const QByteArray &text, const QDateTime &when)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(text);
file.flush(); //else closing writes, and sets the time to now
QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime));
}
static QByteArray read(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
private slots:
void initTestCase() { QVERIFY(m_dir.isValid()); }
// Each save keeps the file as it was, newest first, named by the time
// that version was saved, in "<name>-backups" beside it; only the
// newest are kept
void keepsTheNewest()
{
const QString project = m_dir.filePath(QStringLiteral("plant.qetz"));
const QDateTime start(QDate(2026, 10, 16), QTime(8, 0, 0));
for (int i = 0 ; i < 7 ; ++i) {
write(project, "version " + QByteArray::number(i), start.addSecs(60 * i));
QVERIFY(PreviousVersions::keep(project, 3));
}
const QStringList kept = PreviousVersions::kept(project);
QCOMPARE(kept.size(), 3);
QCOMPARE(read(kept.at(0)), QByteArray("version 6"));
QCOMPARE(read(kept.at(2)), QByteArray("version 4"));
QCOMPARE(QFileInfo(kept.at(0)).fileName(), QStringLiteral("plant 2026-10-16 08-06-00.qetz"));
QCOMPARE(QFileInfo(kept.at(0)).absolutePath(), m_dir.filePath(QStringLiteral("plant-backups")));
// The project itself is untouched
QCOMPARE(read(project), QByteArray("version 6"));
}
// Saving the same bytes again keeps no second copy
void sameBytesNotKeptTwice()
{
const QString project = m_dir.filePath(QStringLiteral("same.qet"));
const QDateTime when(QDate(2026, 10, 16), QTime(9, 0, 0));
write(project, "unchanged", when);
QVERIFY(PreviousVersions::keep(project, 5));
write(project, "unchanged", when.addSecs(30));
QVERIFY(PreviousVersions::keep(project, 5));
QCOMPARE(PreviousVersions::kept(project).size(), 1);
}
// Two versions saved the same second both kept
void sameSecond()
{
const QString project = m_dir.filePath(QStringLiteral("fast.qetz"));
const QDateTime when(QDate(2026, 10, 16), QTime(10, 0, 0));
write(project, "first", when);
QVERIFY(PreviousVersions::keep(project, 5));
write(project, "second", when);
QVERIFY(PreviousVersions::keep(project, 5));
QCOMPARE(PreviousVersions::kept(project).size(), 2);
}
// Nothing kept for a new file, or when the count is 0; another
// project's versions in the same folder are not counted
void nothingToKeep()
{
const QString project = m_dir.filePath(QStringLiteral("new.qetz"));
QVERIFY(PreviousVersions::keep(project, 5));
QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project)));
write(project, "x", QDateTime(QDate(2026, 10, 16), QTime(11, 0, 0)));
QVERIFY(PreviousVersions::keep(project, 0));
QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project)));
QVERIFY(PreviousVersions::kept(m_dir.filePath(QStringLiteral("other.qetz"))).isEmpty());
}
// A copy that cannot be made says so, and leaves the project as it is
void copyFails()
{
const QString project = m_dir.filePath(QStringLiteral("blocked.qetz"));
write(project, "data", QDateTime(QDate(2026, 10, 16), QTime(12, 0, 0)));
// A file where the folder should be
write(PreviousVersions::folder(project), "", QDateTime::currentDateTime());
QString error;
QVERIFY(!PreviousVersions::keep(project, 5, &error));
QVERIFY(!error.isEmpty());
QCOMPARE(read(project), QByteArray("data"));
}
};
QTEST_GUILESS_MAIN(tst_previousversions)
#include "tst_previousversions.moc"
+146
View File
@@ -3,6 +3,7 @@
#include <QDir>
#include <QFile>
#include <QtEndian>
#include <functional>
@@ -533,6 +534,38 @@ private slots:
QVERIFY(!error.isEmpty());
}
// A page of the database overwritten, as a bad disk or a broken copy
// would: refused, and said to be damaged (quick_check on opening),
// whichever table the page held
void damagedDatabaseRefused_data()
{
QTest::addColumn<double>("where");
QTest::newRow("second page") << 0.0;
QTest::newRow("middle page") << 0.5;
QTest::newRow("last page") << 1.0;
}
void damagedDatabaseRefused()
{
QFETCH(double, where);
QDomDocument original;
QList<QetZip::Entry> entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original);
bool damaged = false;
for (QetZip::Entry &entry : entries) {
if (entry.name != QLatin1String("project.sqlite")) continue;
const int page_size = qFromBigEndian<quint16>(entry.data.constData() + 16);
const int pages = int(entry.data.size() / page_size);
QVERIFY(pages > 3);
const int page = 1 + int(where * (pages - 2));
entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff)));
damaged = true;
}
QVERIFY(damaged);
QDomDocument back;
QString error;
QVERIFY(!QetContainer::join(entries, &back, &error));
QVERIFY2(error.contains(QStringLiteral("damaged")), qPrintable(error));
}
// project.sqlite changed by @p sql in @p entries
static void alterDatabase(QList<QetZip::Entry> &entries, const char *sql)
{
@@ -764,6 +797,119 @@ private slots:
QString error;
QVERIFY(!QetContainer::join({{QStringLiteral("mimetype"), "application/zip", false}}, &back, &error));
}
// recover() reads what it can of a damaged .qetz: each kind of damage
// costs what it held and nothing else, and is said
void recoverLeavesOutOnlyTheDamage_data()
{
QTest::addColumn<QString>("damage");
QTest::addColumn<QString>("said");
QTest::addColumn<int>("folios");
QTest::addColumn<int>("wires");
QTest::newRow("nothing") << QString() << QString() << 0 << 0;
QTest::newRow("folio file missing") << QStringLiteral("folio") << QStringLiteral("is missing") << -1 << 0;
QTest::newRow("wire row deleted") << QStringLiteral("row") << QStringLiteral("not in the database") << 0 << -1;
QTest::newRow("database page overwritten") << QStringLiteral("page") << QStringLiteral("cannot be read") << 0 << -1000;
}
void recoverLeavesOutOnlyTheDamage()
{
QFETCH(QString, damage);
QFETCH(QString, said);
QFETCH(int, folios);
QFETCH(int, wires);
QDomDocument original;
QList<QetZip::Entry> entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original);
QVERIFY(!entries.isEmpty());
auto count = [](const QDomDocument &d, const QString &tag) {
return int(d.documentElement().elementsByTagName(tag).size());
};
const int folios_before = count(original, QStringLiteral("diagram"));
const int wires_before = count(original, QStringLiteral("conductor"));
if (damage == QLatin1String("folio")) {
for (int i = 0 ; i < entries.size() ; ++i)
if (entries.at(i).name.startsWith(QStringLiteral("folios/"))) { entries.removeAt(i); break; }
} else if (damage == QLatin1String("row")) {
alterDatabase(entries, "DELETE FROM conductor WHERE rowid = (SELECT min(rowid) FROM conductor)");
} else if (damage == QLatin1String("page")) {
for (QetZip::Entry &entry : entries) {
if (entry.name != QLatin1String("project.sqlite")) continue;
const int page_size = qFromBigEndian<quint16>(entry.data.constData() + 16);
const int page = int(entry.data.size() / page_size / 2);
entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff)));
}
}
QByteArray zip;
QString error;
QVERIFY2(QetZip::toBytes(entries, &error).size() > 0, qPrintable(error));
zip = QetZip::toBytes(entries, &error);
QDomDocument back;
QStringList lost;
QVERIFY2(QetContainer::recover(zip, &back, &lost, &error), qPrintable(error));
if (said.isEmpty()) {
QVERIFY2(lost.isEmpty(), qPrintable(lost.join(QLatin1Char('\n'))));
} else {
QVERIFY2(lost.join(QLatin1Char('\n')).contains(said), qPrintable(lost.join(QLatin1Char('\n'))));
// Strict reading refuses it
QDomDocument strict;
QVERIFY(!QetContainer::join(entries, &strict));
}
QCOMPARE(count(back, QStringLiteral("diagram")), folios_before + folios);
if (wires == -1000)
QCOMPARE(count(back, QStringLiteral("conductor")), 0);
else
QCOMPARE(count(back, QStringLiteral("conductor")), wires_before + wires);
// Nothing of the container's own left in the document
QVERIFY(!back.toString().contains(QStringLiteral("qetz-")));
}
// A picture's file missing (F118 wrote such projects): the picture is
// left out, the rest read
void recoverMissingPicture()
{
QList<QetZip::Entry> entries;
QString error;
QVERIFY2(QetZip::fromBytes(read(QStringLiteral(QET_FIXTURES_DIR "/qetz_format2.qetz")), &entries, &error),
qPrintable(error));
QDomDocument document;
QVERIFY2(QetContainer::join(entries, &document, &error), qPrintable(error));
// A picture on the first folio, then saved and its file dropped
QDomElement folio = document.documentElement().firstChildElement(QStringLiteral("diagram"));
QDomElement images = folio.firstChildElement(QStringLiteral("images"));
if (images.isNull()) images = folio.appendChild(document.createElement(QStringLiteral("images"))).toElement();
QDomElement image = document.createElement(QStringLiteral("image"));
image.setAttribute(QStringLiteral("type"), QStringLiteral("png"));
image.appendChild(document.createTextNode(QStringLiteral(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")));
images.appendChild(image);
QList<QetZip::Entry> saved = QetContainer::split(document, QString(), &error);
QVERIFY2(!saved.isEmpty(), qPrintable(error));
bool dropped = false;
for (int i = 0 ; i < saved.size() ; ++i)
if (saved.at(i).name.startsWith(QStringLiteral("images/"))) { saved.removeAt(i); dropped = true; break; }
QVERIFY(dropped);
QDomDocument strict;
QVERIFY(!QetContainer::join(saved, &strict, &error));
QVERIFY(error.contains(QStringLiteral("picture")));
QDomDocument back;
QStringList lost;
QVERIFY2(QetContainer::recover(QetZip::toBytes(saved), &back, &lost, &error), qPrintable(error));
QCOMPARE(lost.size(), 1);
QVERIFY(lost.first().contains(QStringLiteral("picture")));
QCOMPARE(int(back.documentElement().elementsByTagName(QStringLiteral("image")).size()), 0);
QVERIFY(back.toString().contains(QStringLiteral("<elementInformation ")));
}
// What cannot be read as a zip at all is still refused
void recoverNotAZipRefused()
{
QDomDocument back;
QStringList lost;
QString error;
QVERIFY(!QetContainer::recover(QByteArray("not a zip").repeated(10), &back, &lost, &error));
QVERIFY(!error.isEmpty());
}
};
QTEST_APPLESS_MAIN(tst_qetcontainer)
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A folio of a .qetz whose title only changed is saved from its items as
// read, not built (Diagram::deferredXmlWithHeader()). Those items name
// their pictures' files in the .qetz, which the new .qetz left out: the
// project saved then refused to open, 'picture "images/..." is missing'
// (FINDINGS F118). Runs scripts through the real binary's --run on a copy
// of examples/grafcet.qet given a picture on its second folio.
class tst_qetzheaderpictures : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// Run @p script on @p project; its output
QString run(const QString &script, const QString &project, bool every_folio_built)
{
const QString path = m_dir.filePath(QStringLiteral("script.js"));
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString();
f.write(script.toUtf8());
f.close();
const QString home = m_dir.filePath(QStringLiteral("home"));
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("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());
if (every_folio_built)
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), {QStringLiteral("--run"), path, project});
proc.waitForFinished(120000);
return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError());
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void titleChangeKeepsPictures()
{
// A 1x1 PNG
const QString png = m_dir.filePath(QStringLiteral("dot.png"));
QFile p(png);
QVERIFY(p.open(QIODevice::WriteOnly));
p.write(QByteArray::fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="));
p.close();
const QString with_picture = m_dir.filePath(QStringLiteral("picture.qetz"));
const QString titled = m_dir.filePath(QStringLiteral("titled.qetz"));
QString out = run(QStringLiteral("qet.log('ADDED ' + qet.addImage(1, '%1', 100, 100));"
"qet.save('%2');").arg(png, with_picture),
QStringLiteral(QET_EXAMPLES_DIR "/grafcet.qet"), true);
QVERIFY2(out.contains(QStringLiteral("ADDED 0")), qPrintable(out));
QVERIFY(QFile::exists(with_picture));
// Only the title of the folio with the picture, folios built
// when needed
out = run(QStringLiteral("qet.log('SET ' + qet.setFolioProperty(1, 'title', 'Edited'));"
"qet.save('%1');").arg(titled), with_picture, false);
QVERIFY2(out.contains(QStringLiteral("SET true")), qPrintable(out));
QVERIFY(QFile::exists(titled));
// Opens, with its picture and the new title
out = run(QStringLiteral("qet.log('PICTURES ' + qet.images(1).length);"
"qet.log('TITLE ' + qet.folioTitle(1));"), titled, true);
QVERIFY2(out.contains(QStringLiteral("PICTURES 1")), qPrintable(out));
QVERIFY2(out.contains(QStringLiteral("TITLE Edited")), qPrintable(out));
}
};
QTEST_APPLESS_MAIN(tst_qetzheaderpictures)
#include "tst_qetzheaderpictures.moc"
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QDomDocument>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A folio report shows where its partner is. Changing any other
// information of the report wrote back the label stored in its
// information, the partner's position when the file was last saved, so
// the report pointed at the wrong place until the partner moved again
// (FINDINGS F116). Runs a script through the real binary's --run on
// examples/iso_sfc_example.qet: the "jump to" of folio 1 moves from
// column 4 to column 9, then the "jump from" on folio 2 gets a comment.
class tst_reporttextafterinfo : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// The text a symbol @p uuid of @p saved shows for its label
static QString labelText(const QString &saved, const QString &uuid)
{
QFile file(saved);
if (!file.open(QIODevice::ReadOnly)) return QStringLiteral("(not saved)");
QDomDocument doc;
if (!doc.setContent(&file)) return QStringLiteral("(not read)");
const QDomNodeList elements = doc.elementsByTagName(QStringLiteral("element"));
for (int i = 0 ; i < elements.size() ; ++i) {
const QDomElement e = elements.at(i).toElement();
if (e.attribute(QStringLiteral("uuid")) != uuid) continue;
const QDomNodeList texts = e.elementsByTagName(QStringLiteral("dynamic_elmt_text"));
for (int j = 0 ; j < texts.size() ; ++j) {
const QDomElement t = texts.at(j).toElement();
if (t.firstChildElement(QStringLiteral("info_name")).text() == QLatin1String("label"))
return t.firstChildElement(QStringLiteral("text")).text();
}
}
return QStringLiteral("(no such text)");
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void reportFollowsItsPartner_data()
{
QTest::addColumn<bool>("every_folio_built");
QTest::newRow("folios built when needed") << false;
QTest::newRow("every folio built") << true;
}
void reportFollowsItsPartner()
{
QFETCH(bool, every_folio_built);
const QString tag = every_folio_built ? QStringLiteral("eager") : QStringLiteral("lazy");
const QString saved = m_dir.filePath(tag + QStringLiteral(".qet"));
const QString script = m_dir.filePath(tag + QStringLiteral(".js"));
QFile f(script);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write("qet.moveElement(0, '{bcf3d2e9-7190-48ed-bbb5-57bcc9ba8cfa}', 300, 0);\n"
"qet.setElementInfo(1, '{016d0266-33ac-4255-8fe2-4d9629a12d86}', 'comment', 'edited');\n"
"qet.save('" + saved.toUtf8() + "');\n");
f.close();
// Columns numbered from 1, as QElectroTech's settings page
// shows by default
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(conf.open(QIODevice::WriteOnly));
conf.write("[General]\nborder-columns_0=false\n");
conf.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"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
if (every_folio_built)
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),
{QStringLiteral("--run"), script,
QStringLiteral(QET_EXAMPLES_DIR "/iso_sfc_example.qet")});
QVERIFY(proc.waitForFinished(120000));
// The partner, moved, and the report that shows where it is
QCOMPARE(labelText(saved, QStringLiteral("{016d0266-33ac-4255-8fe2-4d9629a12d86}")),
QStringLiteral("1-C9"));
}
};
QTEST_APPLESS_MAIN(tst_reporttextafterinfo)
#include "tst_reporttextafterinfo.moc"