diff --git a/src/Utils/BootSnapshot.h b/src/Utils/BootSnapshot.h index 86ae038c63..79a3bed2e8 100644 --- a/src/Utils/BootSnapshot.h +++ b/src/Utils/BootSnapshot.h @@ -32,7 +32,10 @@ namespace Util::Settings explicit constexpr BootSnapshot(const RestartTable& table) noexcept : table_(table.data()), tableSize_(N) { - static_assert(std::is_standard_layout_v, "BootSnapshot requires standard-layout Settings for offsetof-based tables."); + // offsetof is conditionally-supported on non-standard-layout types, but MSVC + // computes correct offsets for scalar fields -- so std::string members are fine. + // Only polymorphic types are rejected (a vtable would shift every offset). + static_assert(!std::is_polymorphic_v, "BootSnapshot does not support polymorphic Settings (member offsets are not stable)."); static_assert(std::is_copy_assignable_v, "BootSnapshot requires copy-assignable Settings."); static_assert(std::is_default_constructible_v, "BootSnapshot requires default-constructible Settings (bootCopy_ default-inits and detail::MemberOffset constructs a temporary)."); diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt index 769dcc84d0..04bdd608d4 100644 --- a/tests/cpp/CMakeLists.txt +++ b/tests/cpp/CMakeLists.txt @@ -35,16 +35,34 @@ endif() find_package(nlohmann_json CONFIG REQUIRED) find_package(imgui CONFIG REQUIRED) +# SphericalHarmonics uses the float2/3/4 = DirectX::SimpleMath::* aliases. +find_package(directxtk CONFIG REQUIRED) +# Input.h uses magic_enum for device enum handling. +find_package(magic_enum CONFIG REQUIRED) add_executable(cpp_tests test_main.cpp test_bootsnapshot.cpp test_subrect.cpp + test_perfutils.cpp + test_restartsettings.cpp + test_sphericalharmonics.cpp + test_input.cpp # Compile the unit-under-test directly into the test binary so we don't # depend on the plugin DLL build (which pulls in FFX/Streamline/etc.). "${CMAKE_SOURCE_DIR}/src/Utils/Subrect.cpp" + "${CMAKE_SOURCE_DIR}/src/Utils/SphericalHarmonics.cpp" ) +# The plugin defines the float2/3/4 = SimpleMath aliases in its PCH, which the +# test target does not use. Force-include a tiny prelude so SphericalHarmonics +# (and its .cpp) see those aliases without the heavy RE/SKSE PCH. +if(MSVC) + target_compile_options(cpp_tests PRIVATE "/FI${CMAKE_CURRENT_SOURCE_DIR}/test_prelude.h") +else() + target_compile_options(cpp_tests PRIVATE -include "${CMAKE_CURRENT_SOURCE_DIR}/test_prelude.h") +endif() + set_property(TARGET cpp_tests PROPERTY CXX_STANDARD 23) set_property(TARGET cpp_tests PROPERTY CXX_STANDARD_REQUIRED ON) @@ -56,6 +74,8 @@ target_link_libraries(cpp_tests PRIVATE Catch2::Catch2 nlohmann_json::nlohmann_json imgui::imgui + Microsoft::DirectXTK + magic_enum::magic_enum ) # windows.h (pulled in via d3d11.h) defines min/max macros that break the diff --git a/tests/cpp/test_input.cpp b/tests/cpp/test_input.cpp new file mode 100644 index 0000000000..8a0fc6dad2 --- /dev/null +++ b/tests/cpp/test_input.cpp @@ -0,0 +1,87 @@ +// Unit tests for the input-combo abstraction (src/Utils/Input.h). +// +// Header-only; depends on magic_enum + nlohmann_json. is needed +// because Input.h's MatchesKeyboardCombo references VK_* / GetAsyncKeyState +// (that function itself isn't tested — it needs live key state). + +#include + +#include "Utils/Input.h" + +#include + +#include +#include +#include + +using json = nlohmann::json; + +TEST_CASE("InputCombo packs device and key, with 16-bit key truncation", "[input]") +{ + const auto kb = InputCombo::Keyboard(0x41); // 'A' + REQUIRE(kb.GetDevice() == InputDeviceType::Keyboard); + REQUIRE(kb.GetKey() == 0x41u); + + // Key is masked to the low 16 bits. + const InputCombo big(InputDeviceType::Mouse, 0x12345); + REQUIRE(big.GetDevice() == InputDeviceType::Mouse); + REQUIRE(big.GetKey() == 0x2345u); +} + +TEST_CASE("Device factories select the right device", "[input]") +{ + REQUIRE(InputCombo::Primary(1).GetDevice() == InputDeviceType::Primary); + REQUIRE(InputCombo::Secondary(1).GetDevice() == InputDeviceType::Secondary); + REQUIRE(InputCombo::Both(1).GetDevice() == InputDeviceType::Both); + REQUIRE(InputCombo::Keyboard(1).GetDevice() == InputDeviceType::Keyboard); + REQUIRE(InputCombo::Mouse(1).GetDevice() == InputDeviceType::Mouse); + REQUIRE(InputCombo::Gamepad(1).GetDevice() == InputDeviceType::Gamepad); +} + +TEST_CASE("IsValid requires an in-range device and a non-zero key", "[input]") +{ + REQUIRE(InputCombo::Keyboard(0x41).IsValid()); + REQUIRE_FALSE(InputCombo{}.IsValid()); // default: key 0 + REQUIRE_FALSE(InputCombo::Keyboard(0).IsValid()); // explicit zero key + REQUIRE_FALSE(InputCombo(static_cast(99), 0x41).IsValid()); // bad device +} + +TEST_CASE("Equality and ordering compare the packed device+key", "[input]") +{ + REQUIRE(InputCombo::Keyboard(0x41) == InputCombo::Keyboard(0x41)); + REQUIRE_FALSE(InputCombo::Keyboard(0x41) == InputCombo::Mouse(0x41)); + // Mouse (device 4) sorts after Keyboard (device 3) since device is the high bits. + REQUIRE(InputCombo::Keyboard(0xFFFF) < InputCombo::Mouse(0x0001)); +} + +TEST_CASE("ToString and IsValidDevice cover the enum range", "[input]") +{ + REQUIRE(std::string(ToString(InputDeviceType::Keyboard)) == "Keyboard"); + REQUIRE(std::string(ToString(InputDeviceType::Primary)) == "Primary"); + REQUIRE(std::string(ToString(static_cast(99))) == "Unknown"); + + REQUIRE(IsValidDevice(InputDeviceType::Primary)); + REQUIRE(IsValidDevice(InputDeviceType::Gamepad)); + REQUIRE_FALSE(IsValidDevice(static_cast(99))); +} + +TEST_CASE("InputCombo serializes as its packed integer and round-trips", "[input]") +{ + const auto kb = InputCombo::Keyboard(0x41); + const json j = kb; + REQUIRE(j.get() == 0x30041u); + + const auto back = j.get(); + REQUIRE(back == kb); +} + +TEST_CASE("A vector of InputCombo round-trips through JSON", "[input]") +{ + const std::vector combo{ + InputCombo::Keyboard(VK_CONTROL), + InputCombo::Keyboard(0x53), // 'S' + }; + const json j = combo; + const auto back = j.get>(); + REQUIRE(back == combo); +} diff --git a/tests/cpp/test_perfutils.cpp b/tests/cpp/test_perfutils.cpp new file mode 100644 index 0000000000..14cd6b5bbd --- /dev/null +++ b/tests/cpp/test_perfutils.cpp @@ -0,0 +1,55 @@ +// Unit tests for Util perf/stat helpers (src/Utils/PerfUtils.h). +// +// Header-only, pure logic; GetNowSecs wraps QueryPerformanceCounter but is +// self-contained. No game/D3D/ImGui dependency. + +#include // LARGE_INTEGER / QueryPerformance* used by PerfUtils.h + +#include "Utils/PerfUtils.h" + +#include +#include + +#include + +using Catch::Approx; + +TEST_CASE("CalcFrameTime converts ticks to milliseconds", "[perfutils]") +{ + REQUIRE(Util::CalcFrameTime(1000, 1000) == Approx(1000.0f)); + REQUIRE(Util::CalcFrameTime(16, 1000) == Approx(16.0f)); + REQUIRE(Util::CalcFrameTime(0, 1000) == Approx(0.0f)); + REQUIRE(Util::CalcFrameTime(1000, 2000) == Approx(500.0f)); +} + +TEST_CASE("CalcFPS guards non-positive frame time", "[perfutils]") +{ + REQUIRE(Util::CalcFPS(0.0f) == 0.0f); + REQUIRE(Util::CalcFPS(-5.0f) == 0.0f); + REQUIRE(Util::CalcFPS(1000.0f) == Approx(1.0f)); + REQUIRE(Util::CalcFPS(1000.0f / 60.0f) == Approx(60.0f)); +} + +TEST_CASE("Mean handles empty, single, and multi-element vectors", "[perfutils]") +{ + REQUIRE(Util::Mean({}) == 0.0f); + REQUIRE(Util::Mean({ 5.0f }) == Approx(5.0f)); + REQUIRE(Util::Mean({ 2.0f, 4.0f, 6.0f }) == Approx(4.0f)); + REQUIRE(Util::Mean({ -1.0f, 1.0f }) == Approx(0.0f)); +} + +TEST_CASE("Median handles empty, odd, even, and unsorted input", "[perfutils]") +{ + REQUIRE(Util::Median({}) == 0.0f); + REQUIRE(Util::Median({ 7.0f }) == Approx(7.0f)); + REQUIRE(Util::Median({ 3.0f, 1.0f, 2.0f }) == Approx(2.0f)); + REQUIRE(Util::Median({ 4.0f, 1.0f, 3.0f, 2.0f }) == Approx(2.5f)); +} + +TEST_CASE("GetNowSecs is non-negative and non-decreasing", "[perfutils]") +{ + const double t0 = Util::GetNowSecs(); + const double t1 = Util::GetNowSecs(); + REQUIRE(t0 >= 0.0); + REQUIRE(t1 >= t0); +} diff --git a/tests/cpp/test_prelude.h b/tests/cpp/test_prelude.h new file mode 100644 index 0000000000..b581ebca63 --- /dev/null +++ b/tests/cpp/test_prelude.h @@ -0,0 +1,20 @@ +#pragma once + +// Test prelude (force-included into the cpp_tests target). +// +// The plugin's PCH (include/PCH.h) defines these DirectXMath/SimpleMath +// aliases project-wide, but the cpp_tests target doesn't use that PCH (it +// would drag in RE/Skyrim.h, SKSE, detours, etc.). Force-including this gives +// units that rely on the aliases (e.g. SphericalHarmonics) the same float +// types so they compile standalone. Harmless for units that don't use them. +#include + +// std facilities the plugin PCH provides transitively and that unit-under-test +// .cpp files rely on (e.g. SphericalHarmonics.cpp uses std::clamp). +#include +#include + +using float2 = DirectX::SimpleMath::Vector2; +using float3 = DirectX::SimpleMath::Vector3; +using float4 = DirectX::SimpleMath::Vector4; +using float4x4 = DirectX::SimpleMath::Matrix; diff --git a/tests/cpp/test_restartsettings.cpp b/tests/cpp/test_restartsettings.cpp new file mode 100644 index 0000000000..5991c89d9f --- /dev/null +++ b/tests/cpp/test_restartsettings.cpp @@ -0,0 +1,58 @@ +// Unit tests for the restart-gated settings metadata helpers +// (src/Utils/RestartSettings.h). Pure std; no game/D3D/ImGui dependency. + +#include "Utils/RestartSettings.h" + +#include + +#include // compare via std::string (Catch2 lacks a linked StringMaker for string_view) + +namespace +{ + // Standard-layout stand-in for a feature Settings struct. + struct FakeSettings + { + int width = 0; + float scale = 0.0f; + bool enabled = false; + }; + + constexpr Util::Settings::RestartTable kFields{ + UTIL_RESTART_FIELD(FakeSettings, width, "Width"), + UTIL_RESTART_FIELD(FakeSettings, scale, "Scale"), + UTIL_RESTART_FIELD(FakeSettings, enabled, "Enabled"), + }; +} + +TEST_CASE("UTIL_RESTART_FIELD records jsonKey, label, offset, size", "[restartsettings]") +{ + REQUIRE(std::string(kFields[0].jsonKey) == "width"); + REQUIRE(std::string(kFields[0].label) == "Width"); + REQUIRE(kFields[0].offset == offsetof(FakeSettings, width)); + REQUIRE(kFields[0].size == sizeof(int)); + + REQUIRE(kFields[1].offset == offsetof(FakeSettings, scale)); + REQUIRE(kFields[1].size == sizeof(float)); + REQUIRE(kFields[2].offset == offsetof(FakeSettings, enabled)); + REQUIRE(kFields[2].size == sizeof(bool)); +} + +TEST_CASE("FindRestartField returns the matching descriptor", "[restartsettings]") +{ + const auto* f = Util::Settings::FindRestartField(kFields, "scale"); + REQUIRE(f != nullptr); + REQUIRE(std::string(f->label) == "Scale"); + REQUIRE(f->offset == offsetof(FakeSettings, scale)); +} + +TEST_CASE("FindRestartField returns nullptr on miss and is case-sensitive", "[restartsettings]") +{ + REQUIRE(Util::Settings::FindRestartField(kFields, "missing") == nullptr); + // jsonKey is the lowercase member name; "Width" must not match "width". + REQUIRE(Util::Settings::FindRestartField(kFields, "Width") == nullptr); +} + +TEST_CASE("FindRestartField over an empty span returns nullptr", "[restartsettings]") +{ + REQUIRE(Util::Settings::FindRestartField({}, "width") == nullptr); +} diff --git a/tests/cpp/test_sphericalharmonics.cpp b/tests/cpp/test_sphericalharmonics.cpp new file mode 100644 index 0000000000..d4d5c94615 --- /dev/null +++ b/tests/cpp/test_sphericalharmonics.cpp @@ -0,0 +1,93 @@ +// Unit tests for the spherical-harmonics math library (src/Utils/SphericalHarmonics.h). +// +// Pure DirectXMath/SimpleMath; no game/D3D/ImGui dependency. The float2/3/4 +// aliases come from the force-included test_prelude.h. + +#include "Utils/SphericalHarmonics.h" + +#include +#include + +#include + +using Catch::Approx; +using SphericalHarmonics::SH2; + +namespace +{ + // Band-0 / band-1 real-SH normalization constants the library hard-codes. + constexpr float K0 = 0.28209479177387814347f; // 1 / (2*sqrt(pi)) + constexpr float K1 = 0.48860251190291992159f; // sqrt(3) / (2*sqrt(pi)) +} + +TEST_CASE("Evaluate emits the band-0 constant and axis-aligned band-1 terms", "[sh]") +{ + // c0 is direction-independent. + REQUIRE(SphericalHarmonics::Evaluate(float3(0, 0, 1)).c0 == Approx(K0)); + REQUIRE(SphericalHarmonics::Evaluate(float3(1, 0, 0)).c0 == Approx(K0)); + + // +Z: only the M=0 (c1[1]) term is non-zero, == +K1. + const SH2 z = SphericalHarmonics::Evaluate(float3(0, 0, 1)); + REQUIRE(z.c1[0] == Approx(0.0f)); + REQUIRE(z.c1[1] == Approx(K1)); + REQUIRE(z.c1[2] == Approx(0.0f)); + + // +X drives c1[2] to -K1; +Y drives c1[0] to -K1. + REQUIRE(SphericalHarmonics::Evaluate(float3(1, 0, 0)).c1[2] == Approx(-K1)); + REQUIRE(SphericalHarmonics::Evaluate(float3(0, 1, 0)).c1[0] == Approx(-K1)); +} + +TEST_CASE("Dot is symmetric and matches the closed form", "[sh]") +{ + const SH2 a(1.0f, 2.0f, 3.0f, 4.0f); + const SH2 b(0.5f, -1.0f, 2.0f, 0.0f); + REQUIRE(SphericalHarmonics::Dot(a, b) == Approx(4.5f)); + REQUIRE(SphericalHarmonics::Dot(a, b) == Approx(SphericalHarmonics::Dot(b, a))); +} + +TEST_CASE("Self-dot of a unit-direction basis equals 1/pi", "[sh]") +{ + // K0^2 + K1^2 == 1/pi for any unit direction (one band-1 term active here). + const SH2 z = SphericalHarmonics::Evaluate(float3(0, 0, 1)); + REQUIRE(SphericalHarmonics::Dot(z, z) == Approx(1.0f / std::numbers::pi_v)); +} + +TEST_CASE("Add is componentwise and commutative", "[sh]") +{ + const SH2 a(1.0f, 2.0f, 3.0f, 4.0f); + const SH2 b(10.0f, 20.0f, 30.0f, 40.0f); + const SH2 s = SphericalHarmonics::Add(a, b); + REQUIRE(s.c0 == Approx(11.0f)); + REQUIRE(s.c1[0] == Approx(22.0f)); + REQUIRE(s.c1[1] == Approx(33.0f)); + REQUIRE(s.c1[2] == Approx(44.0f)); + + const SH2 s2 = SphericalHarmonics::Add(b, a); + REQUIRE(s.c0 == Approx(s2.c0)); + REQUIRE(s.c1[2] == Approx(s2.c1[2])); +} + +TEST_CASE("Scale is linear; scaling by zero zeroes all coefficients", "[sh]") +{ + const SH2 a(1.0f, -2.0f, 3.0f, -4.0f); + const SH2 d = SphericalHarmonics::Scale(a, 2.0f); + REQUIRE(d.c0 == Approx(2.0f)); + REQUIRE(d.c1[0] == Approx(-4.0f)); + REQUIRE(d.c1[1] == Approx(6.0f)); + REQUIRE(d.c1[2] == Approx(-8.0f)); + + const SH2 z = SphericalHarmonics::Scale(a, 0.0f); + REQUIRE(z.c0 == Approx(0.0f)); + REQUIRE(z.c1[0] == Approx(0.0f)); + REQUIRE(z.c1[1] == Approx(0.0f)); + REQUIRE(z.c1[2] == Approx(0.0f)); +} + +TEST_CASE("Unproject of a pure band-0 SH returns c0 * K0 in every direction", "[sh]") +{ + // A DC-only SH (band-1 zero) projects to the same value regardless of dir. + const SH2 dc(3.0f, 0.0f, 0.0f, 0.0f); + REQUIRE(SphericalHarmonics::Unproject(float3(0, 0, 1), dc) == Approx(3.0f * K0)); + REQUIRE(SphericalHarmonics::Unproject(float3(1, 0, 0), dc) == Approx(3.0f * K0)); + REQUIRE(SphericalHarmonics::Unproject(float3(0, -1, 0), dc) == Approx(3.0f * K0)); +}