diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index 3153dc76..d899ff7a 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -1,6 +1,9 @@ name: LeoCAD CI on: + pull_request: + branches: [ "master" ] + workflow_dispatch: push: branches: [ "master" ] @@ -24,18 +27,36 @@ jobs: run: qmake PREFIX=/usr - name: Build run: make -j ${{ steps.cpu-cores.outputs.count }} + - name: Generate test Makefile + run: | + mkdir -p build/tests + cd build/tests + qmake ../../tests/tests.pro CONFIG+=release CONFIG-=debug + - name: Build tests + working-directory: build/tests + run: make -j ${{ steps.cpu-cores.outputs.count }} + - name: Run tests + working-directory: build/tests + run: make check TESTARGS="-o test-results.xml,junitxml -o -,txt" + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ runner.os }} + path: build/tests/test-results.xml + if-no-files-found: ignore build-macos: runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Qt uses: jurplel/install-qt-action@v4 with: version: '6.11.*' cache: 'true' - name: Cache Library - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-library with: path: library.bin @@ -46,7 +67,7 @@ jobs: wget https://github.com/leozide/leocad/releases/download/v26.09/Library-26.08.zip -O library.zip unzip library.zip - name: Cache POV-Ray - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-povray with: path: povray @@ -66,3 +87,61 @@ jobs: run: qmake -early QMAKE_DEFAULT_LIBDIRS=$(xcrun -show-sdk-path)/usr/lib PREFIX=/usr - name: Build run: make -j ${{ steps.cpu-cores.outputs.count }} + - name: Generate test Makefile + run: | + mkdir -p build/tests + cd build/tests + qmake ../../tests/tests.pro CONFIG+=release CONFIG-=debug + - name: Build tests + working-directory: build/tests + run: make -j ${{ steps.cpu-cores.outputs.count }} + - name: Run tests + working-directory: build/tests + run: make check TESTARGS="-o test-results.xml,junitxml -o -,txt" + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ runner.os }} + path: build/tests/test-results.xml + if-no-files-found: ignore + + build-windows: + runs-on: windows-2022 + defaults: + run: + shell: cmd + steps: + - uses: actions/checkout@v4 + - name: Set up MSVC + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.11.*' + arch: 'win64_msvc2022_64' + cache: 'true' + - name: Generate Makefile + run: qmake -spec win32-msvc CONFIG+=release CONFIG-=debug CONFIG-=debug_and_release + - name: Build + run: nmake /NOLOGO + - name: Generate test Makefile + run: | + mkdir build\tests + cd build\tests + qmake ..\..\tests\tests.pro -spec win32-msvc CONFIG+=release CONFIG-=debug CONFIG-=debug_and_release + - name: Build tests + working-directory: build/tests + run: nmake /NOLOGO + - name: Run tests + working-directory: build/tests + run: nmake /NOLOGO check "TESTARGS=-o test-results.xml,junitxml -o -,txt" + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ runner.os }} + path: build/tests/test-results.xml + if-no-files-found: ignore diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..02c124b2 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,74 @@ +# LeoCAD tests + +All tests run in one Qt Test executable, `leocad_tests`. The current tests cover +object history and property-key comparisons, including 8,000 deterministic +randomized undo/redo and merge transitions. + +The executable creates a `QCoreApplication`; it does not open windows or require +an OpenGL context, the parts library, or network access. It uses the same Qt +headers as the application, plus Qt Test, and supports Qt 5 and Qt 6. + +## Build and run + +From the repository root, with the intended Qt installation's `qmake` on PATH: + +```sh +mkdir -p build/tests +cd build/tests +qmake ../../tests/tests.pro CONFIG+=release CONFIG-=debug +make -j +make check +``` + +On Windows, use the make tool for the selected Qt kit, such as `nmake`. + +To list test functions or run one function directly: + +```sh +./leocad_tests -functions +./leocad_tests HistoryStoresOnlyChangedObjects +``` + +On Windows, the executable is `leocad_tests.exe` and may be in `release/`. +`make check` handles the configured executable location. + +To write both console output and a JUnit report: + +```sh +make check TESTARGS="-o test-results.xml,junitxml -o -,txt" +``` + +## Add tests + +1. Declare a named `void` function under `private slots` in `lc_tests.h`. +2. Define it in the relevant test source file as `void lcTests::FunctionName()`. +3. If adding a new source file, list it in `tests.pro`. + +Use `QVERIFY`, `QCOMPARE`, or other Qt Test checks so assertions remain active in +release builds and failures identify the function and source line. Each test +must create its own state. Use temporary directories for writable fixtures and +fixed seeds for randomized tests; include the seed and iteration in failures. +Tests should call production code directly. + +Application-level tests needing more source files should declare those sources +and dependencies in `tests.pro`; shared source lists can move into `.pri` files +when needed. Keep application startup and packaging out of the test target. + +## GitHub CI + +`.github/workflows/continuous.yml` builds and runs this target on Linux (Qt 5), +macOS (Qt 6), and Windows x64 (Qt 6 with MSVC 2022), on pull requests to `master`, +pushes to `master`, and manual runs. JUnit reports are uploaded as artifacts, +including on test failure. Adding a test does not require changing the workflow +or `main.cpp`. + +The Windows job uses `nmake` and a single release configuration. To reproduce its +commands in an x64 MSVC developer command prompt with Qt on PATH: + +```bat +mkdir build\tests +cd build\tests +qmake ..\..\tests\tests.pro -spec win32-msvc CONFIG+=release CONFIG-=debug CONFIG-=debug_and_release +nmake /NOLOGO +nmake /NOLOGO check "TESTARGS=-o test-results.xml,junitxml -o -,txt" +``` diff --git a/tests/lc_objecthistory_test.cpp b/tests/lc_objecthistory_test.cpp new file mode 100644 index 00000000..d00092c0 --- /dev/null +++ b/tests/lc_objecthistory_test.cpp @@ -0,0 +1,234 @@ +#include "lc_global.h" +#include "lc_tests.h" +#include "lc_objecthistory.h" +#include "lc_objectproperty.h" +#include + +class TestProperty : public lcObjectProperty +{ +public: + using lcObjectProperty::lcObjectProperty; + + void AddKey(lcStep Step, int Value) + { + mKeys.push_back({ Step, Value }); + } +}; + +struct TestState +{ + uint32_t Id; + int Value; + + bool operator==(const TestState& Other) const + { + return Id == Other.Id && Value == Other.Value; + } +}; + +struct TestObject +{ + TestState State = {}; + int Loads = 0; + inline static int Destructions = 0; + + ~TestObject() { Destructions++; } + + uint32_t GetId() const { return State.Id; } + TestState GetHistoryState() const { return State; } +}; + +using History = lcObjectHistory; +using Objects = std::vector>; + +static History Capture(const Objects& Model) +{ + History State; + State.Save(Model); + return State; +} + +static void Load(const History& State, Objects& Model, int& Removed) +{ + const int DestructionsBefore = TestObject::Destructions; + const std::unordered_map ObjectsById = State.RestoreObjects(Model); + Removed += TestObject::Destructions - DestructionsBefore; + + for (const TestState& Saved : State.States) + { + const auto Object = ObjectsById.find(Saved.Id); + + if (Object != ObjectsById.end()) + { + Object->second->State = Saved; + Object->second->Loads++; + } + } +} + +static bool Matches(const Objects& Model, const std::vector& States) +{ + if (Model.size() != States.size()) + return false; + + for (size_t Index = 0; Index < States.size(); Index++) + if (!(Model[Index]->State == States[Index])) + return false; + + return true; +} + +static void Add(Objects& Model, uint32_t Id, int Value) +{ + Model.emplace_back(std::make_unique()); + Model.back()->State = { Id, Value }; +} + +void lcTests::PropertyKeysAffectEquality() +{ + TestProperty Property(10); + TestProperty OtherProperty(10); + QVERIFY(Property == OtherProperty); + OtherProperty.AddKey(1, 10); + QVERIFY(!(Property == OtherProperty)); + Property.AddKey(1, 10); + QVERIFY(Property == OtherProperty); + OtherProperty.AddKey(2, 20); + QVERIFY(!(Property == OtherProperty)); +} + +void lcTests::HistoryStoresOnlyChangedObjects() +{ + Objects Model; + + for (uint32_t Id = 1; Id <= 15000; Id++) + Add(Model, Id, 0); + + History Start = Capture(Model); + Model[7500]->State.Value = 1; + History End = Capture(Model); + History::Compact(Start, End); + QCOMPARE(Start.States.size(), size_t(1)); + QCOMPARE(End.States.size(), size_t(1)); + QCOMPARE(Start.States.capacity(), size_t(1)); + QCOMPARE(End.States.capacity(), size_t(1)); + QVERIFY(!Start.Order && !End.Order); + TestObject* Untouched = Model[0].get(); + int Removed = 0; + Load(Start, Model, Removed); + QCOMPARE(Model[7500]->State.Value, 0); + Load(End, Model, Removed); + QCOMPARE(Model[7500]->State.Value, 1); + QVERIFY(Model[0].get() == Untouched && Model[0]->Loads == 0 && Removed == 0); +} + +void lcTests::HistoryCancelsPendingEdit() +{ + Objects Model; + + for (uint32_t Id = 1; Id <= 3; Id++) + Add(Model, Id, 0); + + const History Start = Capture(Model); + const std::vector Expected = Start.States; + Model.erase(Model.begin()); + Add(Model, 4, 3); + + int Removed = 0; + Load(Start, Model, Removed); + QVERIFY(Matches(Model, Expected)); + QCOMPARE(Removed, 1); +} + +void lcTests::HistoryReplaysAndMergesEdits() +{ + Objects Model; + History Start; + History End; + int Removed = 0; + + // Compare merged deltas with full snapshots over mixed edits. This covers + // insert/delete, reordering, no-ops, and changing an object back to its start. + std::mt19937 Random(1050); + uint32_t NextId = 1; + + for (int Trial = 0; Trial < 200; Trial++) + { + Model.clear(); + + for (int Index = 0; Index < 12; Index++) + Add(Model, NextId++, 0); + + const std::vector Initial = Capture(Model).States; + History MergedStart; + History MergedEnd; + + for (int Edit = 0; Edit < 40; Edit++) + { + Start = Capture(Model); + + switch (Random() % 5) + { + case 0: + Add(Model, NextId++, static_cast(Random() % 10)); + break; + case 1: + if (!Model.empty()) + Model.erase(Model.begin() + Random() % Model.size()); + break; + case 2: + if (!Model.empty()) + Model[Random() % Model.size()]->State.Value = static_cast(Random() % 10); + break; + case 3: + if (!Model.empty()) + std::swap(Model[Random() % Model.size()], Model[Random() % Model.size()]); + break; + case 4: + break; + } + + End = Capture(Model); + const std::vector Expected = End.States; + const std::vector Previous = Start.States; + History::Compact(Start, End); + Load(Start, Model, Removed); + QVERIFY2(Matches(Model, Previous), qPrintable(QString("Undo failed: seed 1050, trial %1, edit %2").arg(Trial).arg(Edit))); + Load(End, Model, Removed); + QVERIFY2(Matches(Model, Expected), qPrintable(QString("Redo failed: seed 1050, trial %1, edit %2").arg(Trial).arg(Edit))); + + if (!Edit) + { + MergedStart = std::move(Start); + MergedEnd = std::move(End); + } + else + History::Merge(MergedStart, MergedEnd, Start, End); + + Load(MergedStart, Model, Removed); + QVERIFY2(Matches(Model, Initial), qPrintable(QString("Merged undo failed: seed 1050, trial %1, edit %2").arg(Trial).arg(Edit))); + Load(MergedEnd, Model, Removed); + QVERIFY2(Matches(Model, Expected), qPrintable(QString("Merged redo failed: seed 1050, trial %1, edit %2").arg(Trial).arg(Edit))); + } + } +} + +void lcTests::HistoryDropsRevertedMerge() +{ + Objects Model; + History Start; + History End; + + // A merged edit that returns to the initial state retains no object data. + Add(Model, 1, 0); + Start = Capture(Model); + Model[0]->State.Value = 1; + End = Capture(Model); + History NextStart = End; + Model[0]->State.Value = 0; + History NextEnd = Capture(Model); + History::Compact(Start, End); + History::Compact(NextStart, NextEnd); + History::Merge(Start, End, NextStart, NextEnd); + QVERIFY(Start.IsEmpty() && End.IsEmpty()); +} diff --git a/tests/lc_tests.h b/tests/lc_tests.h new file mode 100644 index 00000000..b5cd9f9c --- /dev/null +++ b/tests/lc_tests.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class lcTests : public QObject +{ + Q_OBJECT + +private slots: + void PropertyKeysAffectEquality(); + void HistoryStoresOnlyChangedObjects(); + void HistoryCancelsPendingEdit(); + void HistoryReplaysAndMergesEdits(); + void HistoryDropsRevertedMerge(); +}; diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 00000000..bc5b5f48 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,3 @@ +#include "lc_tests.h" + +QTEST_GUILESS_MAIN(lcTests) diff --git a/tests/tests.pro b/tests/tests.pro new file mode 100644 index 00000000..793899eb --- /dev/null +++ b/tests/tests.pro @@ -0,0 +1,19 @@ +TEMPLATE = app +TARGET = leocad_tests + +CONFIG += console testcase c++17 +CONFIG -= app_bundle + +QT += concurrent opengl printsupport testlib widgets + +greaterThan(QT_MAJOR_VERSION, 5) { + QT += openglwidgets +} + +INCLUDEPATH += ../common + +HEADERS += lc_tests.h + +SOURCES += \ + lc_objecthistory_test.cpp \ + main.cpp