From b3816caadb4cd5e12510349712a49098f412dc97 Mon Sep 17 00:00:00 2001 From: Leonardo Zide Date: Wed, 21 Oct 2026 10:59:46 -0700 Subject: [PATCH] Add Qt Test runner and Windows CI build Provide a single qmake test executable with named Qt Test cases and a standard make check entry point. Cover object-history compaction, cancellation, merging, and property-key comparisons with checks that remain active in release builds, and document how to add tests. Build and run the tests in Linux and macOS CI and add a Windows x64 job using MSVC 2022 and Qt 6. Enable pull request and manual runs alongside master pushes, preserve JUnit reports on failures, and update existing checkout and cache actions. Validation: macOS Qt 6.10 release tests and actionlint passed. Confirmed a failing assertion produces a nonzero check result and a JUnit failure. Closes #1050 --- .github/workflows/continuous.yml | 85 ++++++++++- tests/README.md | 74 ++++++++++ tests/lc_objecthistory_test.cpp | 234 +++++++++++++++++++++++++++++++ tests/lc_tests.h | 15 ++ tests/main.cpp | 3 + tests/tests.pro | 19 +++ 6 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/lc_objecthistory_test.cpp create mode 100644 tests/lc_tests.h create mode 100644 tests/main.cpp create mode 100644 tests/tests.pro 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