diff --git a/CMakeLists.txt b/CMakeLists.txt index bd685a45d..7fbe2f5de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -146,6 +146,25 @@ endif() # als laatse include(cmake/define_definitions.cmake) +# The application's code, compiled once, as an object library: the +# executable is main.cpp linked with it, and tests that need the real +# classes (a folio, its view, the editor window) link it too. +set(QET_MAIN_FILE ${QET_DIR}/sources/main.cpp) +list(REMOVE_ITEM QET_SRC_FILES ${QET_MAIN_FILE}) +add_library( + ${PROJECT_NAME}_objects + OBJECT + ${QET_RES_FILES} + ${QET_SRC_FILES} + ${QET_DIR}/qelectrotech.qrc + ${QET_DIR}/ico/icon-themes.qrc +) +# The definitions above are set for this whole directory; a test target +# lives in tests/ and gets them from here, through the library. +get_directory_property(QET_DIRECTORY_DEFINITIONS COMPILE_DEFINITIONS) +target_compile_definitions(${PROJECT_NAME}_objects + INTERFACE ${QET_DIRECTORY_DEFINITIONS}) + # On Windows, WIN32 sets /SUBSYSTEM:WINDOWS to suppress the console window. # Qt automatically links qtmain.lib which provides the WinMain entry point, # so no source code change is needed. @@ -153,11 +172,8 @@ if(WIN32) add_executable( ${PROJECT_NAME} WIN32 - ${QET_RES_FILES} - ${QET_SRC_FILES} + ${QET_MAIN_FILE} ${QM_FILES} - ${QET_DIR}/qelectrotech.qrc - ${QET_DIR}/ico/icon-themes.qrc ) # Our own manifest (it declares longPathAware). MSVC merges a .manifest # listed as a source; MinGW needs it embedded through a resource file, @@ -174,13 +190,11 @@ if(WIN32) else() add_executable( ${PROJECT_NAME} - ${QET_RES_FILES} - ${QET_SRC_FILES} + ${QET_MAIN_FILE} ${QM_FILES} - ${QET_DIR}/qelectrotech.qrc - ${QET_DIR}/ico/icon-themes.qrc ) endif() +target_link_libraries(${PROJECT_NAME} PRIVATE ${PROJECT_NAME}_objects) if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED) list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::SPNAV) @@ -249,7 +263,7 @@ elseif(Qt6_VERSION VERSION_LESS "6.7") # Automatically creates the update_translations umbrella target. qt_add_lupdate( - ${PROJECT_NAME} + ${PROJECT_NAME}_objects TS_FILES ${TS_FILES} ) @@ -262,7 +276,7 @@ else() ) qt_add_lupdate( - SOURCE_TARGETS ${PROJECT_NAME} + SOURCE_TARGETS ${PROJECT_NAME}_objects TS_FILES ${TS_FILES} LUPDATE_TARGET update_translations NO_GLOBAL_TARGET @@ -288,7 +302,7 @@ if(QET_ENABLE_PCH) "QET_ENABLE_PCH needs CMake 3.16 or newer (found ${CMAKE_VERSION}); " "building without precompiled headers.") else() - target_precompile_headers(${PROJECT_NAME} PRIVATE + target_precompile_headers(${PROJECT_NAME}_objects PRIVATE "$<$:>" "$<$:>" "$<$:>" @@ -299,9 +313,8 @@ if(QET_ENABLE_PCH) endif() target_link_libraries( - ${PROJECT_NAME} + ${PROJECT_NAME}_objects PUBLIC - PRIVATE pugixml::pugixml SingleApplication::SingleApplication SQLite3::SQLite3 @@ -311,8 +324,8 @@ target_link_libraries( ) target_include_directories( - ${PROJECT_NAME} - PRIVATE + ${PROJECT_NAME}_objects + PUBLIC ${QET_DIR}/sources/titleblock ${QET_DIR}/sources/ui ${QET_DIR}/sources/qetgraphicsitem @@ -350,8 +363,8 @@ target_include_directories( if(NOT BUILD_WITH_KF) target_include_directories( - ${PROJECT_NAME} - PRIVATE + ${PROJECT_NAME}_objects + PUBLIC ${QET_DIR}/sources/ui/nokde ) endif() diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index 19e7ebdda..f80da1583 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -2465,6 +2465,10 @@ void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardMo { prepareGeometryChange(); (index == 0 ? m_P1 : m_P2) = localPos; + // The same rule as setLine() and setRect(): a pivot nobody placed + // by hand follows the centre. + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); repositionHandles(); emit geometryChanged(); return; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 8fc750de0..9ea46caf6 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -244,6 +244,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( @@ -256,6 +272,10 @@ set_tests_properties(tst_textresizehandles PROPERTIES ENVIRONMENT "QT_QPA_PLATFO target_include_directories(tst_textresizehandles PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textresizehandles PRIVATE Qt::Test Qt::Widgets) +# A line's pivot follows its centre when one of its ends is dragged, as a +# rectangle's does; a pivot placed by hand stays. +qet_add_app_test(tst_linepivotresize tst_linepivotresize.cpp) + # legacyelementtypes.h is header-only: the rewrite of element type names in # nomenclature queries saved before June 2022. add_executable(tst_legacyelementtypes tst_legacyelementtypes.cpp) 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_linepivotresize.cpp b/tests/qttest/tst_linepivotresize.cpp new file mode 100644 index 000000000..78f7eb5d7 --- /dev/null +++ b/tests/qttest/tst_linepivotresize.cpp @@ -0,0 +1,163 @@ +/* + 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 "QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "qetgraphicsitem/qetshapeitem.h" + +#include + +/** + A shape's pivot follows its centre when it is resized, unless it was + placed by hand. Rectangles and ellipses did; a line did not: dragging + one of its ends moved the end and left the pivot where it was, so the + next rotation turned the line around a point beside it. + + The handles are pressed and dragged with scene mouse events, as the + view sends them. +*/ +class tst_linepivotresize : public QObject +{ + Q_OBJECT + + Diagram *m_diagram = nullptr; + QetShapeItem *m_shape = nullptr; + + void send(QGraphicsItem *item, QEvent::Type type, const QPointF &scenePos) + { + QGraphicsSceneMouseEvent event(type); + event.setButton(Qt::LeftButton); + event.setButtons(type == QEvent::GraphicsSceneMouseRelease ? Qt::NoButton : Qt::LeftButton); + event.setScenePos(scenePos); + event.setPos(item->mapFromScene(scenePos)); + event.setButtonDownScenePos(Qt::LeftButton, scenePos); + m_diagram->sendEvent(item, &event); + } + + QGraphicsItem *handleAt(const QPointF &scenePos) + { + QGraphicsItem *best = nullptr; + qreal bestDistance = 0; + for (QGraphicsItem *item : m_diagram->items()) + { + if (item->type() != QetGraphicsHandlerItem::Type) + continue; + const qreal d = QLineF(item->scenePos(), scenePos).length(); + if (!best || d < bestDistance) + { + best = item; + bestDistance = d; + } + } + return best; + } + + void drag(QGraphicsItem *handle, const QPointF &delta) + { + QVERIFY(handle); + const QPointF from = handle->scenePos(); + send(handle, QEvent::GraphicsSceneMousePress, from); + send(handle, QEvent::GraphicsSceneMouseMove, from + delta / 2); + send(handle, QEvent::GraphicsSceneMouseMove, from + delta); + send(handle, QEvent::GraphicsSceneMouseRelease, from + delta); + } + + /// A click on the selected shape: the next handle mode. + void click(const QPointF &local) + { + const QPointF at = m_shape->mapToScene(local); + send(m_shape, QEvent::GraphicsSceneMousePress, at); + send(m_shape, QEvent::GraphicsSceneMouseRelease, at); + } + + QetShapeItem *add(QetShapeItem::ShapeType type, const QPointF &p1, const QPointF &p2) + { + auto *shape = new QetShapeItem(p1, p2, type); + m_diagram->addItem(shape); + shape->setPos(200, 200); + shape->setSelected(true); + return shape; + } + +private slots: + void initTestCase() + { + QVERIFY(QetAppTest::editor()); + m_diagram = QetAppTest::diagram(); + QVERIFY(m_diagram); + } + + void cleanup() + { + m_diagram->clearSelection(); + delete m_shape; + m_shape = nullptr; + m_diagram->undoStack().clear(); + } + + void lineEndDragKeepsThePivotAtTheCentre() + { + m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0)); + QCOMPARE(m_shape->pivot(), QPointF(50, 0)); + + drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30)); + + QCOMPARE(m_shape->line().p2(), QPointF(140, 30)); + QCOMPARE(m_shape->pivot(), m_shape->line().center()); + QVERIFY(!m_shape->pivotIsCustom()); + } + + void undoAndRedoKeepItAtTheCentre() + { + m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0)); + drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30)); + for (int round = 0; round < 2; ++round) + { + m_diagram->undoStack().undo(); + QCOMPARE(m_shape->line().p2(), QPointF(100, 0)); + QCOMPARE(m_shape->pivot(), QPointF(50, 0)); + m_diagram->undoStack().redo(); + QCOMPARE(m_shape->pivot(), m_shape->line().center()); + } + } + + void handPlacedPivotStays() + { + m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0)); + click(QPointF(25, 0)); // rotate/skew mode + drag(handleAt(m_shape->mapToScene(m_shape->pivot())), QPointF(20, 10)); + QVERIFY(m_shape->pivotIsCustom()); + const QPointF pivot = m_shape->pivot(); + click(QPointF(25, 0)); // back to resize mode + + drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30)); + + QCOMPARE(m_shape->line().p2(), QPointF(140, 30)); + QCOMPARE(m_shape->pivot(), pivot); + } + + // What a line now does, as a rectangle always did. + void rectangleResizeKeepsThePivotAtTheCentre() + { + m_shape = add(QetShapeItem::Rectangle, QPointF(0, 0), QPointF(100, 60)); + drag(handleAt(m_shape->mapToScene(QPointF(100, 60))), QPointF(40, 30)); + QCOMPARE(m_shape->pivot(), QRectF(QPointF(0, 0), QPointF(140, 90)).center()); + } +}; + +QET_APP_TEST_MAIN(tst_linepivotresize) +#include "tst_linepivotresize.moc" 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"