Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Utils/BootSnapshot.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ namespace Util::Settings
explicit constexpr BootSnapshot(const RestartTable<SettingsT, N>& table) noexcept :
table_(table.data()), tableSize_(N)
{
static_assert(std::is_standard_layout_v<SettingsT>, "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<SettingsT>, "BootSnapshot does not support polymorphic Settings (member offsets are not stable).");
static_assert(std::is_copy_assignable_v<SettingsT>, "BootSnapshot requires copy-assignable Settings.");
static_assert(std::is_default_constructible_v<SettingsT>,
"BootSnapshot requires default-constructible Settings (bootCopy_ default-inits and detail::MemberOffset constructs a temporary).");
Expand Down
20 changes: 20 additions & 0 deletions tests/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions tests/cpp/test_input.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Unit tests for the input-combo abstraction (src/Utils/Input.h).
//
// Header-only; depends on magic_enum + nlohmann_json. <Windows.h> is needed
// because Input.h's MatchesKeyboardCombo references VK_* / GetAsyncKeyState
// (that function itself isn't tested — it needs live key state).

#include <Windows.h>

#include "Utils/Input.h"

#include <catch2/catch_test_macros.hpp>

#include <nlohmann/json.hpp>
#include <string>
#include <vector>

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<InputDeviceType>(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<InputDeviceType>(99))) == "Unknown");

REQUIRE(IsValidDevice(InputDeviceType::Primary));
REQUIRE(IsValidDevice(InputDeviceType::Gamepad));
REQUIRE_FALSE(IsValidDevice(static_cast<InputDeviceType>(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<uint32_t>() == 0x30041u);

const auto back = j.get<InputCombo>();
REQUIRE(back == kb);
}

TEST_CASE("A vector of InputCombo round-trips through JSON", "[input]")
{
const std::vector<InputCombo> combo{
InputCombo::Keyboard(VK_CONTROL),
InputCombo::Keyboard(0x53), // 'S'
};
const json j = combo;
const auto back = j.get<std::vector<InputCombo>>();
REQUIRE(back == combo);
}
55 changes: 55 additions & 0 deletions tests/cpp/test_perfutils.cpp
Original file line number Diff line number Diff line change
@@ -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 <Windows.h> // LARGE_INTEGER / QueryPerformance* used by PerfUtils.h

#include "Utils/PerfUtils.h"

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

#include <vector>

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);
}
20 changes: 20 additions & 0 deletions tests/cpp/test_prelude.h
Original file line number Diff line number Diff line change
@@ -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 <SimpleMath.h>

// std facilities the plugin PCH provides transitively and that unit-under-test
// .cpp files rely on (e.g. SphericalHarmonics.cpp uses std::clamp).
#include <algorithm>
#include <cmath>

using float2 = DirectX::SimpleMath::Vector2;
using float3 = DirectX::SimpleMath::Vector3;
using float4 = DirectX::SimpleMath::Vector4;
using float4x4 = DirectX::SimpleMath::Matrix;
58 changes: 58 additions & 0 deletions tests/cpp/test_restartsettings.cpp
Original file line number Diff line number Diff line change
@@ -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 <catch2/catch_test_macros.hpp>

#include <string> // 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<FakeSettings, 3> 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);
}
93 changes: 93 additions & 0 deletions tests/cpp/test_sphericalharmonics.cpp
Original file line number Diff line number Diff line change
@@ -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 <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

#include <numbers>

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<float>));
}

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));
}
Loading