diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dc812d3..95ee47c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,7 @@ - [ ] `cmake --preset windows-debug` - [ ] `cmake --build --preset build-debug` +- [ ] `ctest --preset test-debug` - [ ] Not run (reason below) ## Risks / Follow-up diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65867b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + run: cmake -S . -B build/ci -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON -DSAFECROWD_BUILD_APP=OFF + + - name: Build + run: cmake --build build/ci --parallel + + - name: Test + run: ctest --test-dir build/ci --output-on-failure diff --git a/AGENTS.md b/AGENTS.md index 09efb3f..bedead8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,30 @@ # AGENTS.md ## Project Overview -- SafeCrowd is a ECS game engine based crowd simulation project. -- Use Qt for UI. +- SafeCrowd is an ECS-based crowd simulation and decision-support project with a Qt desktop application. - Keep the architecture layered: `application -> domain -> engine`. +- Product/architecture documents currently lead the implementation, so verify tracked source files before assuming a module already exists in `src/`. + +## Current Repo State +- Declared CMake targets: + - `ecs_engine` + - `safecrowd_domain` + - `safecrowd_app` +- The repository currently includes build configuration, docs, UML diagrams, GitHub workflow/templates, and vendored third-party code under `external/`. +- Source roots are still expected under: + - `src/application` + - `src/domain` + - `src/engine` +- When touching build files, confirm that referenced source files are actually tracked in Git. ## Build - Configure: `cmake --preset windows-debug` - Build: `cmake --build --preset build-debug` +- Test: `ctest --preset test-debug` - App target: `safecrowd_app` +- UI dependency: Qt6 via `vcpkg.json` (`qtbase`) +- If configure/build fails, check preset/Visual Studio selection and `vcpkg`/Qt availability before assuming the code change caused it. +- PR CI currently validates the engine/domain/test path with `-DSAFECROWD_BUILD_APP=OFF` for fast feedback; keep the full Qt app build healthy locally. ## Source Layout - All C++ source files live under `src/`. @@ -17,28 +33,62 @@ - `#include "application/..."` - `#include "domain/..."` - `#include "engine/..."` +- Supporting repository areas: + - `docs/` for requirements, architecture, and project-management notes + - `uml/` for PlantUML diagrams and explanations + - `.github/` for repository workflow/policy files + - `external/` for vendored dependencies that must remain in-tree ## Architecture Rules - `engine` must not depend on `domain` or `application`. - `domain` must not depend on Qt UI code. - `application` is responsible for wiring UI to domain logic. +- If a change affects multiple layers, review dependency direction first and keep responsibilities explicit. ## Dependency Policy - Prefer dependencies declared in `vcpkg.json`. - Use `external/` only for vendored libraries that must live in-tree. +- `external/glad/` is currently tracked as vendored third-party code. - Do not leave unused third-party code in `external/`. +## GitHub Workflow +- Use GitHub issue forms for new work items; blank issues are disabled. +- Issue types currently supported: + - `Epic` for larger parent work + - `Task` for single implementation/analysis/docs work +- GitHub Project guidance is documented in `docs/GitHub Project.md`. +- PR titles must follow `[Area] short summary`. +- Allowed PR areas: + - `Engine` + - `Domain` + - `Application` + - `Docs` + - `Build` + - `Analysis` + - `Chore` +- PR bodies should follow `.github/PULL_REQUEST_TEMPLATE.md`. +- `main` is protected: + - merge through PR only + - squash merge is the intended merge mode + - required PR check: `Validate PR` + - build/test checks should stay aligned with `.github/workflows/ci.yml` + ## Editing Guidelines - Keep changes minimal and localized. - Preserve existing naming/style unless there is a clear reason to refactor. -- Update docs when structure or build rules change. +- Update docs when structure, build rules, or repository workflow changes. +- When changing contribution workflow files, keep `CONTRIBUTING.md` and `.github/` files aligned. ## Docs - Architecture notes: `docs/프로젝트 구조.md` +- Project workflow notes: `docs/GitHub Project.md` - Requirements and overview docs are under `docs/`. ## Review Priorities - Broken build or preset mismatch +- Unit test regression or missing CTest wiring +- Missing tracked source files referenced by `CMakeLists.txt` - Layer dependency violations - Qt code leaking into `domain` - Unused or confusing dependency setup +- Drift between `CONTRIBUTING.md` and `.github/` workflow/template files diff --git a/CMakeLists.txt b/CMakeLists.txt index 40d5695..6098b27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,16 +5,22 @@ project(SafeCrowd LANGUAGES CXX ) +include(CTest) + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) +option(SAFECROWD_BUILD_APP "Build the Qt desktop application" ON) + +if (SAFECROWD_BUILD_APP) + set(CMAKE_AUTOMOC ON) + set(CMAKE_AUTOUIC ON) + set(CMAKE_AUTORCC ON) -find_package(Qt6 CONFIG REQUIRED COMPONENTS Core Gui Widgets) -qt_standard_project_setup() + find_package(Qt6 CONFIG REQUIRED COMPONENTS Core Gui Widgets) + qt_standard_project_setup() +endif() set(SAFECROWD_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src") @@ -64,23 +70,50 @@ target_link_libraries(safecrowd_domain configure_project_target(safecrowd_domain) -add_executable(safecrowd_app - src/application/main.cpp - src/application/MainWindow.cpp - src/application/MainWindow.h -) - -target_include_directories(safecrowd_app - PRIVATE - ${SAFECROWD_SRC_DIR} -) - -target_link_libraries(safecrowd_app - PRIVATE - safecrowd_domain - Qt6::Core - Qt6::Gui - Qt6::Widgets -) - -configure_project_target(safecrowd_app) +if (BUILD_TESTING) + add_executable(safecrowd_tests + tests/TestMain.cpp + tests/TestSupport.h + tests/FrameClockTests.cpp + tests/EngineRuntimeTests.cpp + tests/SafeCrowdDomainTests.cpp + ) + + target_include_directories(safecrowd_tests + PRIVATE + ${SAFECROWD_SRC_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/tests + ) + + target_link_libraries(safecrowd_tests + PRIVATE + safecrowd_domain + ) + + configure_project_target(safecrowd_tests) + + add_test(NAME safecrowd_tests COMMAND safecrowd_tests) +endif() + +if (SAFECROWD_BUILD_APP) + add_executable(safecrowd_app + src/application/main.cpp + src/application/MainWindow.cpp + src/application/MainWindow.h + ) + + target_include_directories(safecrowd_app + PRIVATE + ${SAFECROWD_SRC_DIR} + ) + + target_link_libraries(safecrowd_app + PRIVATE + safecrowd_domain + Qt6::Core + Qt6::Gui + Qt6::Widgets + ) + + configure_project_target(safecrowd_app) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 9d5d9d9..0b151c9 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -37,5 +37,23 @@ "configurePreset": "windows-release", "configuration": "Release" } + ], + "testPresets": [ + { + "name": "test-debug", + "configurePreset": "windows-debug", + "configuration": "Debug", + "output": { + "outputOnFailure": true + } + }, + { + "name": "test-release", + "configurePreset": "windows-release", + "configuration": "Release", + "output": { + "outputOnFailure": true + } + } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bab9ae..0dfbb92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ PR 제목은 아래 형식을 따릅니다. - 연결된 issue - 변경이 속한 영역 - 아키텍처 규칙 점검 결과 -- 빌드/검증 결과 또는 미실행 사유 +- 빌드/테스트 검증 결과 또는 미실행 사유 - 남은 리스크나 후속 작업 ## 아키텍처 체크 @@ -71,6 +71,7 @@ PR 작성 시 아래 항목을 항상 점검합니다. ```powershell cmake --preset windows-debug cmake --build --preset build-debug +ctest --preset test-debug ``` 실행하지 못했다면 PR 본문에 이유를 남깁니다. diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp new file mode 100644 index 0000000..d0820c8 --- /dev/null +++ b/src/application/MainWindow.cpp @@ -0,0 +1,99 @@ +#include "application/MainWindow.h" + +#include +#include +#include +#include +#include + +#include "domain/SafeCrowdDomain.h" +#include "engine/EngineState.h" + +namespace { + +QString stateToString(safecrowd::engine::EngineState state) { + using safecrowd::engine::EngineState; + + switch (state) { + case EngineState::Stopped: + return "Stopped"; + case EngineState::Ready: + return "Ready"; + case EngineState::Running: + return "Running"; + case EngineState::Paused: + return "Paused"; + } + + return "Unknown"; +} + +} // namespace + +namespace safecrowd::application { + +MainWindow::MainWindow(safecrowd::domain::SafeCrowdDomain& domain, QWidget* parent) + : QMainWindow(parent), + domain_(domain) { + auto* centralWidget = new QWidget(this); + auto* layout = new QVBoxLayout(centralWidget); + statusLabel_ = new QLabel(this); + + auto* startButton = new QPushButton("Start", this); + auto* pauseButton = new QPushButton("Pause", this); + auto* stopButton = new QPushButton("Stop", this); + + layout->addWidget(statusLabel_); + layout->addWidget(startButton); + layout->addWidget(pauseButton); + layout->addWidget(stopButton); + + tickTimer_ = new QTimer(this); + tickTimer_->setInterval(16); + + connect(startButton, &QPushButton::clicked, this, [this]() { startSimulation(); }); + connect(pauseButton, &QPushButton::clicked, this, [this]() { pauseSimulation(); }); + connect(stopButton, &QPushButton::clicked, this, [this]() { stopSimulation(); }); + connect(tickTimer_, &QTimer::timeout, this, [this]() { tickSimulation(); }); + + setCentralWidget(centralWidget); + setWindowTitle("SafeCrowd"); + resize(420, 220); + + refreshStatusLabel(); +} + +void MainWindow::startSimulation() { + domain_.start(); + tickTimer_->start(); + refreshStatusLabel(); +} + +void MainWindow::pauseSimulation() { + domain_.pause(); + tickTimer_->stop(); + refreshStatusLabel(); +} + +void MainWindow::stopSimulation() { + domain_.stop(); + tickTimer_->stop(); + refreshStatusLabel(); +} + +void MainWindow::tickSimulation() { + domain_.update(1.0 / 60.0); + refreshStatusLabel(); +} + +void MainWindow::refreshStatusLabel() { + const auto summary = domain_.summary(); + statusLabel_->setText( + QString("State: %1\nFrames: %2\nFixed Steps: %3\nAlpha: %4") + .arg(stateToString(summary.state)) + .arg(summary.frameIndex) + .arg(summary.fixedStepIndex) + .arg(summary.alpha, 0, 'f', 2)); +} + +} // namespace safecrowd::application diff --git a/src/application/MainWindow.h b/src/application/MainWindow.h new file mode 100644 index 0000000..d2c5465 --- /dev/null +++ b/src/application/MainWindow.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace safecrowd::domain { +class SafeCrowdDomain; +} + +class QLabel; +class QTimer; + +namespace safecrowd::application { + +class MainWindow : public QMainWindow { +public: + explicit MainWindow(safecrowd::domain::SafeCrowdDomain& domain, QWidget* parent = nullptr); + +private: + void startSimulation(); + void pauseSimulation(); + void stopSimulation(); + void tickSimulation(); + void refreshStatusLabel(); + + safecrowd::domain::SafeCrowdDomain& domain_; + QLabel* statusLabel_{nullptr}; + QTimer* tickTimer_{nullptr}; +}; + +} // namespace safecrowd::application diff --git a/src/application/main.cpp b/src/application/main.cpp new file mode 100644 index 0000000..3bbda22 --- /dev/null +++ b/src/application/main.cpp @@ -0,0 +1,16 @@ +#include + +#include "application/MainWindow.h" +#include "domain/SafeCrowdDomain.h" +#include "engine/EngineRuntime.h" + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + safecrowd::engine::EngineRuntime runtime; + safecrowd::domain::SafeCrowdDomain domain(runtime); + safecrowd::application::MainWindow window(domain); + window.show(); + + return app.exec(); +} diff --git a/src/domain/SafeCrowdDomain.cpp b/src/domain/SafeCrowdDomain.cpp new file mode 100644 index 0000000..14b2dd6 --- /dev/null +++ b/src/domain/SafeCrowdDomain.cpp @@ -0,0 +1,43 @@ +#include "domain/SafeCrowdDomain.h" + +namespace safecrowd::domain { + +SafeCrowdDomain::SafeCrowdDomain(engine::EngineRuntime& runtime) + : runtime_(runtime) { +} + +void SafeCrowdDomain::start() { + runtime_.play(); +} + +void SafeCrowdDomain::pause() { + runtime_.pause(); +} + +void SafeCrowdDomain::stop() { + runtime_.stop(); +} + +void SafeCrowdDomain::update(double deltaSeconds) { + runtime_.stepFrame(deltaSeconds); +} + +SimulationSummary SafeCrowdDomain::summary() const { + const auto& stats = runtime_.stats(); + return { + .state = stats.state, + .frameIndex = stats.frameIndex, + .fixedStepIndex = stats.fixedStepIndex, + .alpha = stats.alpha, + }; +} + +engine::EngineRuntime& SafeCrowdDomain::runtime() noexcept { + return runtime_; +} + +const engine::EngineRuntime& SafeCrowdDomain::runtime() const noexcept { + return runtime_; +} + +} // namespace safecrowd::domain diff --git a/src/domain/SafeCrowdDomain.h b/src/domain/SafeCrowdDomain.h new file mode 100644 index 0000000..458110b --- /dev/null +++ b/src/domain/SafeCrowdDomain.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "engine/EngineRuntime.h" +#include "engine/EngineState.h" + +namespace safecrowd::domain { + +struct SimulationSummary { + engine::EngineState state{engine::EngineState::Stopped}; + std::uint64_t frameIndex{0}; + std::uint64_t fixedStepIndex{0}; + double alpha{0.0}; +}; + +class SafeCrowdDomain { +public: + explicit SafeCrowdDomain(engine::EngineRuntime& runtime); + + void start(); + void pause(); + void stop(); + void update(double deltaSeconds); + + SimulationSummary summary() const; + engine::EngineRuntime& runtime() noexcept; + const engine::EngineRuntime& runtime() const noexcept; + +private: + engine::EngineRuntime& runtime_; +}; + +} // namespace safecrowd::domain diff --git a/src/engine/EngineConfig.h b/src/engine/EngineConfig.h new file mode 100644 index 0000000..a7e9a61 --- /dev/null +++ b/src/engine/EngineConfig.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace safecrowd::engine { + +struct EngineConfig { + double fixedDeltaTime{1.0 / 60.0}; + std::uint32_t maxCatchUpSteps{4}; + std::uint64_t baseSeed{1}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineRuntime.cpp b/src/engine/EngineRuntime.cpp new file mode 100644 index 0000000..1dfc923 --- /dev/null +++ b/src/engine/EngineRuntime.cpp @@ -0,0 +1,99 @@ +#include "engine/EngineRuntime.h" + +namespace safecrowd::engine { +namespace { + +EngineConfig normalizeConfig(EngineConfig config) { + if (config.fixedDeltaTime <= 0.0) { + config.fixedDeltaTime = 1.0 / 60.0; + } + + if (config.maxCatchUpSteps == 0) { + config.maxCatchUpSteps = 1; + } + + if (config.baseSeed == 0) { + config.baseSeed = 1; + } + + return config; +} + +} // namespace + +EngineRuntime::EngineRuntime(EngineConfig config) + : config_(normalizeConfig(config)), + frameClock_(config_) { +} + +void EngineRuntime::initialize() { + frameClock_.reset(); + stats_ = {}; + stats_.state = EngineState::Ready; + ++runIndex_; +} + +void EngineRuntime::play() { + if (stats_.state == EngineState::Stopped) { + initialize(); + } + + stats_.state = EngineState::Running; +} + +void EngineRuntime::pause() { + if (stats_.state == EngineState::Running) { + stats_.state = EngineState::Paused; + } +} + +void EngineRuntime::stop() { + frameClock_.reset(); + stats_ = {}; + stats_.state = EngineState::Stopped; +} + +void EngineRuntime::stepFrame(double deltaSeconds) { + if (stats_.state == EngineState::Stopped) { + initialize(); + } + + frameClock_.beginFrame(deltaSeconds); + + ++stats_.frameIndex; + stats_.fixedStepsThisFrame = 0; + + while (frameClock_.shouldRunFixedStep()) { + frameClock_.consumeFixedStep(); + ++stats_.fixedStepIndex; + ++stats_.fixedStepsThisFrame; + } + + stats_.alpha = frameClock_.alpha(); +} + +EngineWorld& EngineRuntime::world() noexcept { + return world_; +} + +const EngineWorld& EngineRuntime::world() const noexcept { + return world_; +} + +const EngineConfig& EngineRuntime::config() const noexcept { + return config_; +} + +const EngineStats& EngineRuntime::stats() const noexcept { + return stats_; +} + +EngineState EngineRuntime::state() const noexcept { + return stats_.state; +} + +std::uint64_t EngineRuntime::runIndex() const noexcept { + return runIndex_; +} + +} // namespace safecrowd::engine diff --git a/src/engine/EngineRuntime.h b/src/engine/EngineRuntime.h new file mode 100644 index 0000000..0da63c9 --- /dev/null +++ b/src/engine/EngineRuntime.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "engine/EngineConfig.h" +#include "engine/EngineStats.h" +#include "engine/EngineSystem.h" +#include "engine/FrameClock.h" + +namespace safecrowd::engine { + +class EngineRuntime { +public: + explicit EngineRuntime(EngineConfig config = {}); + + void initialize(); + void play(); + void pause(); + void stop(); + void stepFrame(double deltaSeconds); + + EngineWorld& world() noexcept; + const EngineWorld& world() const noexcept; + const EngineConfig& config() const noexcept; + const EngineStats& stats() const noexcept; + EngineState state() const noexcept; + std::uint64_t runIndex() const noexcept; + +private: + EngineConfig config_; + EngineStats stats_; + EngineWorld world_; + FrameClock frameClock_; + std::uint64_t runIndex_{0}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineState.h b/src/engine/EngineState.h new file mode 100644 index 0000000..89d529a --- /dev/null +++ b/src/engine/EngineState.h @@ -0,0 +1,12 @@ +#pragma once + +namespace safecrowd::engine { + +enum class EngineState { + Stopped, + Ready, + Running, + Paused, +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineStats.h b/src/engine/EngineStats.h new file mode 100644 index 0000000..60cb1e2 --- /dev/null +++ b/src/engine/EngineStats.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "engine/EngineState.h" + +namespace safecrowd::engine { + +struct EngineStats { + EngineState state{EngineState::Stopped}; + std::uint64_t frameIndex{0}; + std::uint64_t fixedStepIndex{0}; + std::uint32_t fixedStepsThisFrame{0}; + double alpha{0.0}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineStepContext.h b/src/engine/EngineStepContext.h new file mode 100644 index 0000000..9a22eb8 --- /dev/null +++ b/src/engine/EngineStepContext.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace safecrowd::engine { + +struct EngineStepContext { + std::uint64_t frameIndex{0}; + std::uint64_t fixedStepIndex{0}; + double alpha{0.0}; + std::uint64_t runIndex{0}; + std::uint64_t derivedSeed{0}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineSystem.h b/src/engine/EngineSystem.h new file mode 100644 index 0000000..943b151 --- /dev/null +++ b/src/engine/EngineSystem.h @@ -0,0 +1,21 @@ +#pragma once + +#include "engine/EngineStepContext.h" + +namespace safecrowd::engine { + +class EngineWorld { +}; + +class EngineSystem { +public: + virtual ~EngineSystem() = default; + + virtual void configure(EngineWorld& world) { + (void)world; + } + + virtual void update(EngineWorld& world, const EngineStepContext& step) = 0; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/FrameClock.cpp b/src/engine/FrameClock.cpp new file mode 100644 index 0000000..27c4e7c --- /dev/null +++ b/src/engine/FrameClock.cpp @@ -0,0 +1,65 @@ +#include "engine/FrameClock.h" + +#include +#include + +namespace safecrowd::engine { +namespace { + +EngineConfig normalizeConfig(EngineConfig config) { + if (config.fixedDeltaTime <= 0.0) { + config.fixedDeltaTime = 1.0 / 60.0; + } + + if (config.maxCatchUpSteps == 0) { + config.maxCatchUpSteps = 1; + } + + return config; +} + +} // namespace + +FrameClock::FrameClock(const EngineConfig& config) + : config_(normalizeConfig(config)) { +} + +void FrameClock::reset() { + accumulatedSeconds_ = 0.0; + pendingFixedSteps_ = 0; +} + +void FrameClock::beginFrame(double deltaSeconds) { + const double safeDeltaSeconds = std::max(0.0, deltaSeconds); + const double maxAccumulatedSeconds = + config_.fixedDeltaTime * static_cast(config_.maxCatchUpSteps); + + accumulatedSeconds_ = std::min(accumulatedSeconds_ + safeDeltaSeconds, maxAccumulatedSeconds); + + const double rawFixedSteps = std::floor(accumulatedSeconds_ / config_.fixedDeltaTime); + pendingFixedSteps_ = static_cast(rawFixedSteps); +} + +bool FrameClock::shouldRunFixedStep() const { + return pendingFixedSteps_ > 0; +} + +void FrameClock::consumeFixedStep() { + if (!shouldRunFixedStep()) { + return; + } + + --pendingFixedSteps_; + accumulatedSeconds_ = std::max(0.0, accumulatedSeconds_ - config_.fixedDeltaTime); +} + +double FrameClock::alpha() const { + const double alphaValue = accumulatedSeconds_ / config_.fixedDeltaTime; + return std::clamp(alphaValue, 0.0, 1.0); +} + +std::uint32_t FrameClock::pendingFixedSteps() const noexcept { + return pendingFixedSteps_; +} + +} // namespace safecrowd::engine diff --git a/src/engine/FrameClock.h b/src/engine/FrameClock.h new file mode 100644 index 0000000..631b6eb --- /dev/null +++ b/src/engine/FrameClock.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "engine/EngineConfig.h" + +namespace safecrowd::engine { + +class FrameClock { +public: + explicit FrameClock(const EngineConfig& config = {}); + + void reset(); + void beginFrame(double deltaSeconds); + bool shouldRunFixedStep() const; + void consumeFixedStep(); + double alpha() const; + std::uint32_t pendingFixedSteps() const noexcept; + +private: + EngineConfig config_; + double accumulatedSeconds_{0.0}; + std::uint32_t pendingFixedSteps_{0}; +}; + +} // namespace safecrowd::engine diff --git a/tests/EngineRuntimeTests.cpp b/tests/EngineRuntimeTests.cpp new file mode 100644 index 0000000..b40db19 --- /dev/null +++ b/tests/EngineRuntimeTests.cpp @@ -0,0 +1,41 @@ +#include "TestSupport.h" + +#include "engine/EngineRuntime.h" + +SC_TEST(EngineRuntimePlayAndStepUpdatesStats) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 10, + }); + + runtime.play(); + runtime.stepFrame(0.50); + + const auto& stats = runtime.stats(); + SC_EXPECT_EQ(runtime.state(), safecrowd::engine::EngineState::Running); + SC_EXPECT_EQ(stats.frameIndex, 1ULL); + SC_EXPECT_EQ(stats.fixedStepIndex, 2ULL); + SC_EXPECT_EQ(stats.fixedStepsThisFrame, 2U); + SC_EXPECT_NEAR(stats.alpha, 0.0, 1e-9); +} + +SC_TEST(EngineRuntimePauseAndStopResetLifecycleState) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 11, + }); + + runtime.play(); + runtime.pause(); + SC_EXPECT_EQ(runtime.state(), safecrowd::engine::EngineState::Paused); + + runtime.stop(); + + const auto& stats = runtime.stats(); + SC_EXPECT_EQ(runtime.state(), safecrowd::engine::EngineState::Stopped); + SC_EXPECT_EQ(stats.frameIndex, 0ULL); + SC_EXPECT_EQ(stats.fixedStepIndex, 0ULL); + SC_EXPECT_NEAR(stats.alpha, 0.0, 1e-9); +} diff --git a/tests/FrameClockTests.cpp b/tests/FrameClockTests.cpp new file mode 100644 index 0000000..19a766e --- /dev/null +++ b/tests/FrameClockTests.cpp @@ -0,0 +1,43 @@ +#include "TestSupport.h" + +#include "engine/FrameClock.h" + +SC_TEST(FrameClockAccumulatesRemainderAcrossFrames) { + safecrowd::engine::FrameClock clock({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 7, + }); + + clock.beginFrame(0.10); + SC_EXPECT_EQ(clock.pendingFixedSteps(), 0U); + SC_EXPECT_NEAR(clock.alpha(), 0.4, 1e-9); + + clock.beginFrame(0.20); + SC_EXPECT_EQ(clock.pendingFixedSteps(), 1U); + SC_EXPECT_TRUE(clock.shouldRunFixedStep()); + + clock.consumeFixedStep(); + SC_EXPECT_EQ(clock.pendingFixedSteps(), 0U); + SC_EXPECT_NEAR(clock.alpha(), 0.2, 1e-9); +} + +SC_TEST(FrameClockCapsCatchUpSteps) { + safecrowd::engine::FrameClock clock({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 2, + .baseSeed = 99, + }); + + clock.beginFrame(2.0); + SC_EXPECT_EQ(clock.pendingFixedSteps(), 2U); + + unsigned int consumedSteps = 0; + while (clock.shouldRunFixedStep()) { + clock.consumeFixedStep(); + ++consumedSteps; + } + + SC_EXPECT_EQ(consumedSteps, 2U); + SC_EXPECT_NEAR(clock.alpha(), 0.0, 1e-9); +} diff --git a/tests/SafeCrowdDomainTests.cpp b/tests/SafeCrowdDomainTests.cpp new file mode 100644 index 0000000..c5e65e6 --- /dev/null +++ b/tests/SafeCrowdDomainTests.cpp @@ -0,0 +1,40 @@ +#include "TestSupport.h" + +#include "domain/SafeCrowdDomain.h" +#include "engine/EngineRuntime.h" + +SC_TEST(SafeCrowdDomainExposesRuntimeSummary) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.5, + .maxCatchUpSteps = 4, + .baseSeed = 42, + }); + + safecrowd::domain::SafeCrowdDomain domain(runtime); + domain.start(); + domain.update(1.0); + + const auto summary = domain.summary(); + SC_EXPECT_EQ(summary.state, safecrowd::engine::EngineState::Running); + SC_EXPECT_EQ(summary.frameIndex, 1ULL); + SC_EXPECT_EQ(summary.fixedStepIndex, 2ULL); + SC_EXPECT_NEAR(summary.alpha, 0.0, 1e-9); +} + +SC_TEST(SafeCrowdDomainStopResetsSummary) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.5, + .maxCatchUpSteps = 4, + .baseSeed = 43, + }); + + safecrowd::domain::SafeCrowdDomain domain(runtime); + domain.start(); + domain.update(0.5); + domain.stop(); + + const auto summary = domain.summary(); + SC_EXPECT_EQ(summary.state, safecrowd::engine::EngineState::Stopped); + SC_EXPECT_EQ(summary.frameIndex, 0ULL); + SC_EXPECT_EQ(summary.fixedStepIndex, 0ULL); +} diff --git a/tests/TestMain.cpp b/tests/TestMain.cpp new file mode 100644 index 0000000..e1b315f --- /dev/null +++ b/tests/TestMain.cpp @@ -0,0 +1,27 @@ +#include +#include + +#include "TestSupport.h" + +int main() { + int failedCount = 0; + + for (const auto& test : safecrowd::tests::registry()) { + try { + test.run(); + std::cout << "[PASS] " << test.name << '\n'; + } catch (const std::exception& ex) { + ++failedCount; + std::cerr << "[FAIL] " << test.name << '\n' + << " " << ex.what() << '\n'; + } + } + + if (failedCount > 0) { + std::cerr << failedCount << " test(s) failed.\n"; + return 1; + } + + std::cout << "All tests passed.\n"; + return 0; +} diff --git a/tests/TestSupport.h b/tests/TestSupport.h new file mode 100644 index 0000000..e150faa --- /dev/null +++ b/tests/TestSupport.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace safecrowd::tests { + +class TestFailure : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +struct TestCase { + std::string name; + std::function run; +}; + +inline std::vector& registry() { + static std::vector tests; + return tests; +} + +struct Registrar { + Registrar(const char* testName, std::function testBody) { + registry().push_back({testName, std::move(testBody)}); + } +}; + +[[noreturn]] inline void fail(const char* file, int line, const std::string& message) { + std::ostringstream stream; + stream << file << ":" << line << ": " << message; + throw TestFailure(stream.str()); +} + +template +std::string stringify(const T& value) { + if constexpr (requires(std::ostream& stream, const T& item) { stream << item; }) { + std::ostringstream stream; + stream << value; + return stream.str(); + } else if constexpr (std::is_enum_v) { + using Underlying = std::underlying_type_t; + return std::to_string(static_cast(value)); + } else { + return ""; + } +} + +inline void expectTrue(bool condition, const char* expr, const char* file, int line) { + if (!condition) { + fail(file, line, std::string("Expected true: ") + expr); + } +} + +template +void expectEqual(const L& lhs, const R& rhs, const char* lhsExpr, const char* rhsExpr, const char* file, int line) { + if (!(lhs == rhs)) { + std::ostringstream stream; + stream << "Expected " << lhsExpr << " == " << rhsExpr << " but got " + << stringify(lhs) << " and " << stringify(rhs); + fail(file, line, stream.str()); + } +} + +inline void expectNear(double lhs, double rhs, double epsilon, const char* lhsExpr, const char* rhsExpr, const char* file, int line) { + if (std::fabs(lhs - rhs) > epsilon) { + std::ostringstream stream; + stream << "Expected " << lhsExpr << " ~= " << rhsExpr << " within " << epsilon + << " but got " << lhs << " and " << rhs; + fail(file, line, stream.str()); + } +} + +} // namespace safecrowd::tests + +#define SC_TEST(name) \ + static void name(); \ + static ::safecrowd::tests::Registrar name##_registrar(#name, &name); \ + static void name() + +#define SC_EXPECT_TRUE(expr) \ + ::safecrowd::tests::expectTrue((expr), #expr, __FILE__, __LINE__) + +#define SC_EXPECT_EQ(lhs, rhs) \ + ::safecrowd::tests::expectEqual((lhs), (rhs), #lhs, #rhs, __FILE__, __LINE__) + +#define SC_EXPECT_NEAR(lhs, rhs, epsilon) \ + ::safecrowd::tests::expectNear((lhs), (rhs), (epsilon), #lhs, #rhs, __FILE__, __LINE__)