diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index 8653c86a4..1a5073f3c 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -235,6 +235,22 @@ set_tests_properties(tst_handlersize PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=off
target_include_directories(tst_handlersize PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_handlersize PRIVATE Qt::Test Qt::Widgets)
+# Tests that need the application around the class they test -- a folio,
+# its view, the editor window and its actions -- link the application's own
+# code (the qelectrotech_objects library) and run a real QETApp offscreen,
+# with settings and folders of their own (qetapptest.h).
+function(qet_add_app_test name)
+ add_executable(${name} ${ARGN} qetapptest.h)
+ add_test(NAME ${name} COMMAND ${name})
+ set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
+ target_include_directories(${name} PRIVATE ${QET_DIR}/sources)
+ target_link_libraries(${name} PRIVATE qelectrotech_objects Qt::Test)
+endfunction()
+
+# Space on a picture turns its own transform, which the file saves (#1413),
+# alone and with a group.
+qet_add_app_test(tst_rotateselectionimage tst_rotateselectionimage.cpp)
+
# TextResizeHandles: the corner handles that change the width of a text box,
# on a plain QGraphicsTextItem with the "textWidth" property QET texts have.
add_executable(
diff --git a/tests/qttest/qetapptest.h b/tests/qttest/qetapptest.h
new file mode 100644
index 000000000..6e93f8ea5
--- /dev/null
+++ b/tests/qttest/qetapptest.h
@@ -0,0 +1,112 @@
+/*
+ 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 .
+*/
+#ifndef QETAPPTEST_H
+#define QETAPPTEST_H
+
+/**
+ For tests that need the application around the class they test: a
+ folio, the view showing it, the editor window and its actions. They
+ link the application's own code (see qet_add_app_test() in
+ CMakeLists.txt) and run a real QETApp, offscreen, with its settings,
+ configuration and data in a folder of their own.
+*/
+
+#include "diagram.h"
+#include "diagramview.h"
+#include "qetapp.h"
+#include "qetarguments.h"
+#include "qetdiagrameditor.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace QetAppTest {
+
+/// The editor window QETApp opened at start.
+inline QETDiagramEditor *editor()
+{
+ const QList editors = QETApp::diagramEditors();
+ return editors.isEmpty() ? nullptr : editors.first();
+}
+
+/// The view of the folio shown in the editor, after a new project of one
+/// folio if the editor has none open.
+inline DiagramView *view()
+{
+ QETDiagramEditor *e = editor();
+ if (!e)
+ return nullptr;
+ if (!e->findChild())
+ e->newProject();
+ return e->findChild();
+}
+
+inline Diagram *diagram()
+{
+ DiagramView *v = view();
+ return v ? v->diagram() : nullptr;
+}
+
+/// The editor's action whose data() is @p name, as the menus, the
+/// toolbar and the keyboard shortcut trigger it ("rotate_selection" is
+/// Space).
+inline QAction *action(const QString &name)
+{
+ QETDiagramEditor *e = editor();
+ if (!e)
+ return nullptr;
+ for (QAction *a : e->findChildren())
+ if (a->data().toString() == name)
+ return a;
+ return nullptr;
+}
+
+/// Run @p test inside a QETApp whose settings, configuration and data
+/// live in a temporary folder, so that no test reads or changes the
+/// user's own.
+template
+int run(int argc, char **argv)
+{
+ QApplication app(argc, argv);
+ QCoreApplication::setOrganizationName("QElectroTech");
+ QCoreApplication::setApplicationName("QElectroTech");
+ QTemporaryDir home;
+ if (!home.isValid())
+ return 1;
+ QSettings::setDefaultFormat(QSettings::IniFormat);
+ QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, home.filePath("settings"));
+ QSettings::setPath(QSettings::IniFormat, QSettings::SystemScope, home.filePath("settings"));
+ QETApp::applyDirectoryArguments(QETArguments(QList{
+ "--config-dir=" + home.filePath("config"),
+ "--data-dir=" + home.filePath("data")}));
+ QETApp qetapp;
+ QCoreApplication::processEvents();
+ Test test;
+ return QTest::qExec(&test, argc, argv);
+}
+
+} // namespace QetAppTest
+
+#define QET_APP_TEST_MAIN(Test) \
+ int main(int argc, char **argv) { return QetAppTest::run(argc, argv); }
+
+#endif
diff --git a/tests/qttest/tst_rotateselectionimage.cpp b/tests/qttest/tst_rotateselectionimage.cpp
new file mode 100644
index 000000000..1b23a0f60
--- /dev/null
+++ b/tests/qttest/tst_rotateselectionimage.cpp
@@ -0,0 +1,152 @@
+/*
+ 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 .
+*/
+#include "qetapptest.h"
+#include "qetgraphicsitem/diagramimageitem.h"
+
+#include
+
+/**
+ Space -- the editor's "rotate_selection" action -- on a picture turns
+ the picture's own transform (rotationAngle, around its pivot), the
+ rotation its file saves. Before #1413 it turned QGraphicsItem's plain
+ rotation, which toXml() does not save, so the picture came back
+ unrotated once the project was reopened.
+*/
+class tst_rotateselectionimage : public QObject
+{
+ Q_OBJECT
+
+ Diagram *m_diagram = nullptr;
+
+ DiagramImageItem *addPicture(const QPointF &pos)
+ {
+ QPixmap pixmap(100, 60);
+ pixmap.fill(Qt::darkGreen);
+ auto *picture = new DiagramImageItem(pixmap);
+ m_diagram->addItem(picture);
+ picture->setPos(pos);
+ return picture;
+ }
+
+ static QPointF pivotInScene(DiagramImageItem *picture)
+ {
+ return picture->mapToScene(picture->pivot());
+ }
+
+ /// The angle and position a file saves, read back by a new picture.
+ static DiagramImageItem *reloaded(DiagramImageItem *picture)
+ {
+ QDomDocument document;
+ auto *copy = new DiagramImageItem();
+ copy->fromXml(picture->toXml(document));
+ return copy;
+ }
+
+ static void trigger(const char *name)
+ {
+ QAction *a = QetAppTest::action(QString::fromLatin1(name));
+ QVERIFY2(a, name);
+ a->trigger();
+ }
+
+private slots:
+ void initTestCase()
+ {
+ QVERIFY(QetAppTest::editor());
+ m_diagram = QetAppTest::diagram();
+ QVERIFY(m_diagram);
+ }
+
+ void cleanup()
+ {
+ m_diagram->clearSelection();
+ for (QGraphicsItem *item : m_diagram->items())
+ if (item->type() == DiagramImageItem::Type)
+ delete item;
+ m_diagram->undoStack().clear();
+ }
+
+ void spaceTurnsThePictureAndTheFileKeepsIt()
+ {
+ DiagramImageItem *picture = addPicture(QPointF(200, 200));
+ const QPointF pivot = pivotInScene(picture);
+ picture->setSelected(true);
+
+ trigger("rotate_selection");
+ trigger("rotate_selection");
+
+ QCOMPARE(picture->rotationAngle(), 180.0);
+ QCOMPARE(picture->rotation(), 0.0); // not QGraphicsItem's own rotation
+ QCOMPARE(pivotInScene(picture), pivot); // turned in place
+
+ std::unique_ptr copy(reloaded(picture));
+ QCOMPARE(copy->rotationAngle(), 180.0);
+ QCOMPARE(copy->pos(), picture->pos());
+ }
+
+ void undoAndRedo()
+ {
+ DiagramImageItem *picture = addPicture(QPointF(200, 200));
+ const QPointF pos = picture->pos();
+ picture->setSelected(true);
+ trigger("rotate_selection");
+
+ // RotateSelectionCommand animates undo and redo after the first
+ // time: the values arrive a moment later.
+ for (int round = 0; round < 2; ++round)
+ {
+ m_diagram->undoStack().undo();
+ QTRY_COMPARE(picture->rotationAngle(), 0.0);
+ QCOMPARE(picture->pos(), pos);
+ m_diagram->undoStack().redo();
+ QTRY_COMPARE(picture->rotationAngle(), 90.0);
+ QCOMPARE(picture->pos(), pos);
+ }
+ }
+
+ // Turned as a group, each picture turns on itself and its pivot moves
+ // a quarter turn around the group's centre (the centre of the
+ // selection's bounding box, snapped to the grid).
+ void groupTurnMovesEachPivotAroundTheCentre()
+ {
+ DiagramImageItem *a = addPicture(QPointF(100, 100));
+ DiagramImageItem *b = addPicture(QPointF(400, 160));
+ a->setSelected(true);
+ b->setSelected(true);
+ const QPointF centre = Diagram::snapToGrid(
+ (a->sceneBoundingRect() | b->sceneBoundingRect()).center());
+ const QTransform quarter = QTransform().translate(centre.x(), centre.y())
+ .rotate(90).translate(-centre.x(), -centre.y());
+ const QPointF expectedA = quarter.map(pivotInScene(a));
+ const QPointF expectedB = quarter.map(pivotInScene(b));
+
+ trigger("rotate_group_selection");
+
+ QCOMPARE(a->rotationAngle(), 90.0);
+ QCOMPARE(b->rotationAngle(), 90.0);
+ QCOMPARE(pivotInScene(a), expectedA);
+ QCOMPARE(pivotInScene(b), expectedB);
+
+ std::unique_ptr copy(reloaded(a));
+ QCOMPARE(copy->rotationAngle(), 90.0);
+ QCOMPARE(pivotInScene(copy.get()) - copy->pos(), pivotInScene(a) - a->pos());
+ }
+};
+
+QET_APP_TEST_MAIN(tst_rotateselectionimage)
+#include "tst_rotateselectionimage.moc"