chore(testing): add unit tests and update core render/network pipelines

- add new tests:
  - test_blp_loader.cpp
  - test_dbc_loader.cpp
  - test_entity.cpp
  - test_frustum.cpp
  - test_m2_structs.cpp
  - test_opcode_table.cpp
  - test_packet.cpp
  - test_srp.cpp
  - CMakeLists.txt
- add docs and progress tracking:
  - TESTING.md
  - perf_baseline.md
- update project config/build:
  - .gitignore
  - CMakeLists.txt
  - test.sh
- core engine updates:
  - application.cpp
  - game_handler.cpp
  - world_socket.cpp
  - adt_loader.cpp
  - asset_manager.cpp
  - m2_renderer.cpp
  - post_process_pipeline.cpp
  - renderer.cpp
  - terrain_manager.cpp
  - game_screen.cpp
- add profiler header:
  - profiler.hpp
This commit is contained in:
Paul 2026-04-03 09:41:34 +03:00
parent a2814ab082
commit 2cb47bf126
25 changed files with 2042 additions and 96 deletions

142
tests/CMakeLists.txt Normal file
View file

@ -0,0 +1,142 @@
# Phase 0: Unit test infrastructure using Catch2 v3 (amalgamated)
# Catch2 amalgamated as a library target
add_library(catch2_main STATIC
${CMAKE_SOURCE_DIR}/extern/catch2/catch_amalgamated.cpp
)
target_include_directories(catch2_main PUBLIC
${CMAKE_SOURCE_DIR}/extern/catch2
)
# Catch2 v3 needs C++17 minimum
target_compile_features(catch2_main PUBLIC cxx_std_17)
# ── ASAN / UBSan propagation ────────────────────────────────
# Collect all test target names so we can apply sanitizer flags at the end.
set(ALL_TEST_TARGETS "")
# Helper: register a test target for ASAN/UBSan if enabled.
macro(register_test_target _target)
list(APPEND ALL_TEST_TARGETS ${_target})
endmacro()
# Shared source files used across multiple tests
set(TEST_COMMON_SOURCES
${CMAKE_SOURCE_DIR}/src/core/logger.cpp
)
# Include directories matching the main target
set(TEST_INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/src
)
set(TEST_SYSTEM_INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/extern
)
# ── test_packet ──────────────────────────────────────────────
add_executable(test_packet
test_packet.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/network/packet.cpp
)
target_include_directories(test_packet PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_packet SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_packet PRIVATE catch2_main)
add_test(NAME packet COMMAND test_packet)
register_test_target(test_packet)
# ── test_srp ─────────────────────────────────────────────────
add_executable(test_srp
test_srp.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/auth/srp.cpp
${CMAKE_SOURCE_DIR}/src/auth/big_num.cpp
${CMAKE_SOURCE_DIR}/src/auth/crypto.cpp
)
target_include_directories(test_srp PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_srp SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_srp PRIVATE catch2_main OpenSSL::SSL OpenSSL::Crypto)
add_test(NAME srp COMMAND test_srp)
register_test_target(test_srp)
# ── test_opcode_table ────────────────────────────────────────
add_executable(test_opcode_table
test_opcode_table.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/game/opcode_table.cpp
)
target_include_directories(test_opcode_table PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_opcode_table SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_opcode_table PRIVATE catch2_main)
add_test(NAME opcode_table COMMAND test_opcode_table)
register_test_target(test_opcode_table)
# ── test_entity ──────────────────────────────────────────────
add_executable(test_entity
test_entity.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/game/entity.cpp
)
target_include_directories(test_entity PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_entity SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_entity PRIVATE catch2_main)
add_test(NAME entity COMMAND test_entity)
register_test_target(test_entity)
# ── test_dbc_loader ──────────────────────────────────────────
add_executable(test_dbc_loader
test_dbc_loader.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/pipeline/dbc_loader.cpp
)
target_include_directories(test_dbc_loader PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_dbc_loader SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_dbc_loader PRIVATE catch2_main)
add_test(NAME dbc_loader COMMAND test_dbc_loader)
register_test_target(test_dbc_loader)
# ── test_m2_structs ──────────────────────────────────────────
# Header-only struct layout tests — no source files needed
add_executable(test_m2_structs
test_m2_structs.cpp
)
target_include_directories(test_m2_structs PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_m2_structs SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_m2_structs PRIVATE catch2_main)
add_test(NAME m2_structs COMMAND test_m2_structs)
register_test_target(test_m2_structs)
# ── test_blp_loader ──────────────────────────────────────────
add_executable(test_blp_loader
test_blp_loader.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/pipeline/blp_loader.cpp
)
target_include_directories(test_blp_loader PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_blp_loader SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_blp_loader PRIVATE catch2_main)
add_test(NAME blp_loader COMMAND test_blp_loader)
register_test_target(test_blp_loader)
# ── test_frustum ─────────────────────────────────────────────
add_executable(test_frustum
test_frustum.cpp
${CMAKE_SOURCE_DIR}/src/rendering/frustum.cpp
)
target_include_directories(test_frustum PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_frustum SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_frustum PRIVATE catch2_main)
add_test(NAME frustum COMMAND test_frustum)
register_test_target(test_frustum)
# ── ASAN / UBSan for test targets ────────────────────────────
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
foreach(_t IN LISTS ALL_TEST_TARGETS)
target_compile_options(${_t} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(${_t} PRIVATE -fsanitize=address,undefined)
endforeach()
# catch2_main must also be compiled with the same flags
target_compile_options(catch2_main PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(catch2_main PRIVATE -fsanitize=address,undefined)
message(STATUS "Test targets: ASAN + UBSan ENABLED")
endif()

92
tests/test_blp_loader.cpp Normal file
View file

@ -0,0 +1,92 @@
// Phase 0 BLP loader tests: isValid, format names, invalid data handling
#include <catch_amalgamated.hpp>
#include "pipeline/blp_loader.hpp"
using wowee::pipeline::BLPLoader;
using wowee::pipeline::BLPImage;
using wowee::pipeline::BLPFormat;
using wowee::pipeline::BLPCompression;
TEST_CASE("BLPImage default is invalid", "[blp]") {
BLPImage img;
REQUIRE_FALSE(img.isValid());
REQUIRE(img.width == 0);
REQUIRE(img.height == 0);
REQUIRE(img.format == BLPFormat::UNKNOWN);
REQUIRE(img.compression == BLPCompression::NONE);
}
TEST_CASE("BLPImage isValid with data", "[blp]") {
BLPImage img;
img.width = 64;
img.height = 64;
img.data.resize(64 * 64 * 4, 0xFF); // RGBA
REQUIRE(img.isValid());
}
TEST_CASE("BLPImage isValid requires non-empty data", "[blp]") {
BLPImage img;
img.width = 64;
img.height = 64;
// data is empty
REQUIRE_FALSE(img.isValid());
}
TEST_CASE("BLPLoader::load empty data returns invalid", "[blp]") {
std::vector<uint8_t> empty;
auto img = BLPLoader::load(empty);
REQUIRE_FALSE(img.isValid());
}
TEST_CASE("BLPLoader::load too small data returns invalid", "[blp]") {
std::vector<uint8_t> tiny = {0x42, 0x4C, 0x50}; // BLP but truncated
auto img = BLPLoader::load(tiny);
REQUIRE_FALSE(img.isValid());
}
TEST_CASE("BLPLoader::load invalid magic returns invalid", "[blp]") {
// Provide enough bytes but with wrong magic
std::vector<uint8_t> bad(256, 0);
bad[0] = 'N'; bad[1] = 'O'; bad[2] = 'T'; bad[3] = '!';
auto img = BLPLoader::load(bad);
REQUIRE_FALSE(img.isValid());
}
TEST_CASE("BLPLoader getFormatName returns non-null", "[blp]") {
REQUIRE(BLPLoader::getFormatName(BLPFormat::UNKNOWN) != nullptr);
REQUIRE(BLPLoader::getFormatName(BLPFormat::BLP1) != nullptr);
REQUIRE(BLPLoader::getFormatName(BLPFormat::BLP2) != nullptr);
// Check that names are distinct
REQUIRE(std::string(BLPLoader::getFormatName(BLPFormat::BLP1)) !=
std::string(BLPLoader::getFormatName(BLPFormat::BLP2)));
}
TEST_CASE("BLPLoader getCompressionName returns non-null", "[blp]") {
REQUIRE(BLPLoader::getCompressionName(BLPCompression::NONE) != nullptr);
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT1) != nullptr);
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT3) != nullptr);
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT5) != nullptr);
REQUIRE(BLPLoader::getCompressionName(BLPCompression::PALETTE) != nullptr);
REQUIRE(BLPLoader::getCompressionName(BLPCompression::ARGB8888) != nullptr);
// Check that DXT names differ
REQUIRE(std::string(BLPLoader::getCompressionName(BLPCompression::DXT1)) !=
std::string(BLPLoader::getCompressionName(BLPCompression::DXT5)));
}
TEST_CASE("BLPImage mipmap storage", "[blp]") {
BLPImage img;
img.width = 128;
img.height = 128;
img.data.resize(128 * 128 * 4, 0);
img.mipLevels = 3;
// Add mipmap data
img.mipmaps.push_back(std::vector<uint8_t>(64 * 64 * 4, 0));
img.mipmaps.push_back(std::vector<uint8_t>(32 * 32 * 4, 0));
img.mipmaps.push_back(std::vector<uint8_t>(16 * 16 * 4, 0));
REQUIRE(img.isValid());
REQUIRE(img.mipmaps.size() == 3);
}

208
tests/test_dbc_loader.cpp Normal file
View file

@ -0,0 +1,208 @@
// Phase 0 DBC binary parsing tests with synthetic data
#include <catch_amalgamated.hpp>
#include "pipeline/dbc_loader.hpp"
#include <cstring>
using wowee::pipeline::DBCFile;
// Build a minimal valid DBC in memory:
// Header: "WDBC" + recordCount(uint32) + fieldCount(uint32) + recordSize(uint32) + stringBlockSize(uint32)
// Records: contiguous fixed-size rows
// String block: null-terminated strings
static std::vector<uint8_t> buildSyntheticDBC(
uint32_t numRecords, uint32_t numFields,
const std::vector<std::vector<uint32_t>>& records,
const std::string& stringBlock)
{
const uint32_t recordSize = numFields * 4;
const uint32_t stringBlockSize = static_cast<uint32_t>(stringBlock.size());
std::vector<uint8_t> data;
// Reserve enough space
data.reserve(20 + numRecords * recordSize + stringBlockSize);
// Magic
data.push_back('W'); data.push_back('D'); data.push_back('B'); data.push_back('C');
auto writeU32 = [&](uint32_t v) {
data.push_back(static_cast<uint8_t>(v & 0xFF));
data.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
data.push_back(static_cast<uint8_t>((v >> 16) & 0xFF));
data.push_back(static_cast<uint8_t>((v >> 24) & 0xFF));
};
writeU32(numRecords);
writeU32(numFields);
writeU32(recordSize);
writeU32(stringBlockSize);
// Records
for (const auto& rec : records) {
for (uint32_t field : rec) {
writeU32(field);
}
}
// String block
for (char c : stringBlock) {
data.push_back(static_cast<uint8_t>(c));
}
return data;
}
TEST_CASE("DBCFile default state", "[dbc]") {
DBCFile dbc;
REQUIRE_FALSE(dbc.isLoaded());
REQUIRE(dbc.getRecordCount() == 0);
REQUIRE(dbc.getFieldCount() == 0);
}
TEST_CASE("DBCFile load valid DBC", "[dbc]") {
// 2 records, 3 fields each: [id, intVal, stringOffset]
// String block: "\0Hello\0World\0" → offset 0="" 1="Hello" 7="World"
std::string strings;
strings += '\0'; // offset 0: empty string
strings += "Hello";
strings += '\0'; // offset 1-6: "Hello"
strings += "World";
strings += '\0'; // offset 7-12: "World"
auto data = buildSyntheticDBC(2, 3,
{
{1, 100, 1}, // Record 0: id=1, intVal=100, stringOffset=1 → "Hello"
{2, 200, 7}, // Record 1: id=2, intVal=200, stringOffset=7 → "World"
},
strings);
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.isLoaded());
REQUIRE(dbc.getRecordCount() == 2);
REQUIRE(dbc.getFieldCount() == 3);
REQUIRE(dbc.getRecordSize() == 12);
REQUIRE(dbc.getStringBlockSize() == strings.size());
}
TEST_CASE("DBCFile getUInt32 and getInt32", "[dbc]") {
auto data = buildSyntheticDBC(1, 2,
{ {42, 0xFFFFFFFF} },
std::string(1, '\0'));
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.getUInt32(0, 0) == 42);
REQUIRE(dbc.getUInt32(0, 1) == 0xFFFFFFFF);
REQUIRE(dbc.getInt32(0, 1) == -1);
}
TEST_CASE("DBCFile getFloat", "[dbc]") {
float testVal = 3.14f;
uint32_t bits;
std::memcpy(&bits, &testVal, 4);
auto data = buildSyntheticDBC(1, 2,
{ {1, bits} },
std::string(1, '\0'));
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.getFloat(0, 1) == Catch::Approx(3.14f));
}
TEST_CASE("DBCFile getString", "[dbc]") {
std::string strings;
strings += '\0';
strings += "TestString";
strings += '\0';
auto data = buildSyntheticDBC(1, 2,
{ {1, 1} }, // field 1 = string offset 1 → "TestString"
strings);
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.getString(0, 1) == "TestString");
}
TEST_CASE("DBCFile getStringView", "[dbc]") {
std::string strings;
strings += '\0';
strings += "ViewTest";
strings += '\0';
auto data = buildSyntheticDBC(1, 2,
{ {1, 1} },
strings);
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.getStringView(0, 1) == "ViewTest");
}
TEST_CASE("DBCFile findRecordById", "[dbc]") {
auto data = buildSyntheticDBC(3, 2,
{
{10, 100},
{20, 200},
{30, 300},
},
std::string(1, '\0'));
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.findRecordById(10) == 0);
REQUIRE(dbc.findRecordById(20) == 1);
REQUIRE(dbc.findRecordById(30) == 2);
REQUIRE(dbc.findRecordById(99) == -1);
}
TEST_CASE("DBCFile getRecord returns pointer", "[dbc]") {
auto data = buildSyntheticDBC(1, 2,
{ {0xAB, 0xCD} },
std::string(1, '\0'));
DBCFile dbc;
REQUIRE(dbc.load(data));
const uint8_t* rec = dbc.getRecord(0);
REQUIRE(rec != nullptr);
// First field should be 0xAB in little-endian
uint32_t val;
std::memcpy(&val, rec, 4);
REQUIRE(val == 0xAB);
}
TEST_CASE("DBCFile load too small data", "[dbc]") {
std::vector<uint8_t> tiny = {'W', 'D', 'B'};
DBCFile dbc;
REQUIRE_FALSE(dbc.load(tiny));
}
TEST_CASE("DBCFile load wrong magic", "[dbc]") {
auto data = buildSyntheticDBC(0, 1, {}, std::string(1, '\0'));
// Corrupt magic
data[0] = 'X';
DBCFile dbc;
REQUIRE_FALSE(dbc.load(data));
}
TEST_CASE("DBCFile getStringByOffset", "[dbc]") {
std::string strings;
strings += '\0';
strings += "Offset5"; // This would be at offset 1 actually, let me be precise
strings += '\0';
auto data = buildSyntheticDBC(1, 1,
{ {0} },
strings);
DBCFile dbc;
REQUIRE(dbc.load(data));
REQUIRE(dbc.getStringByOffset(1) == "Offset5");
REQUIRE(dbc.getStringByOffset(0).empty());
}

220
tests/test_entity.cpp Normal file
View file

@ -0,0 +1,220 @@
// Phase 0 Entity, Unit, Player, GameObject, EntityManager tests
#include <catch_amalgamated.hpp>
#include "game/entity.hpp"
#include <memory>
using namespace wowee::game;
TEST_CASE("Entity default construction", "[entity]") {
Entity e;
REQUIRE(e.getGuid() == 0);
REQUIRE(e.getType() == ObjectType::OBJECT);
REQUIRE(e.getX() == 0.0f);
REQUIRE(e.getY() == 0.0f);
REQUIRE(e.getZ() == 0.0f);
REQUIRE(e.getOrientation() == 0.0f);
}
TEST_CASE("Entity GUID constructor", "[entity]") {
Entity e(0xDEADBEEF);
REQUIRE(e.getGuid() == 0xDEADBEEF);
}
TEST_CASE("Entity position set/get", "[entity]") {
Entity e;
e.setPosition(1.0f, 2.0f, 3.0f, 1.57f);
REQUIRE(e.getX() == Catch::Approx(1.0f));
REQUIRE(e.getY() == Catch::Approx(2.0f));
REQUIRE(e.getZ() == Catch::Approx(3.0f));
REQUIRE(e.getOrientation() == Catch::Approx(1.57f));
}
TEST_CASE("Entity field set/get/has", "[entity]") {
Entity e;
REQUIRE_FALSE(e.hasField(10));
e.setField(10, 0xCAFE);
REQUIRE(e.hasField(10));
REQUIRE(e.getField(10) == 0xCAFE);
// Overwrite
e.setField(10, 0xBEEF);
REQUIRE(e.getField(10) == 0xBEEF);
// Non-existent returns 0
REQUIRE(e.getField(999) == 0);
}
TEST_CASE("Unit construction and type", "[entity]") {
Unit u;
REQUIRE(u.getType() == ObjectType::UNIT);
Unit u2(0x123);
REQUIRE(u2.getGuid() == 0x123);
REQUIRE(u2.getType() == ObjectType::UNIT);
}
TEST_CASE("Unit name", "[entity]") {
Unit u;
REQUIRE(u.getName().empty());
u.setName("Hogger");
REQUIRE(u.getName() == "Hogger");
}
TEST_CASE("Unit health", "[entity]") {
Unit u;
REQUIRE(u.getHealth() == 0);
REQUIRE(u.getMaxHealth() == 0);
u.setHealth(500);
u.setMaxHealth(1000);
REQUIRE(u.getHealth() == 500);
REQUIRE(u.getMaxHealth() == 1000);
}
TEST_CASE("Unit power by type", "[entity]") {
Unit u;
u.setPowerType(0); // mana
u.setPower(200);
u.setMaxPower(500);
REQUIRE(u.getPower() == 200);
REQUIRE(u.getMaxPower() == 500);
REQUIRE(u.getPowerByType(0) == 200);
REQUIRE(u.getMaxPowerByType(0) == 500);
// Set rage (type 1)
u.setPowerByType(1, 50);
u.setMaxPowerByType(1, 100);
REQUIRE(u.getPowerByType(1) == 50);
REQUIRE(u.getMaxPowerByType(1) == 100);
// Out of bounds clamps
REQUIRE(u.getPowerByType(7) == 0);
REQUIRE(u.getMaxPowerByType(7) == 0);
}
TEST_CASE("Unit level, entry, displayId", "[entity]") {
Unit u;
REQUIRE(u.getLevel() == 1); // default
u.setLevel(80);
REQUIRE(u.getLevel() == 80);
u.setEntry(1234);
REQUIRE(u.getEntry() == 1234);
u.setDisplayId(5678);
REQUIRE(u.getDisplayId() == 5678);
}
TEST_CASE("Unit flags", "[entity]") {
Unit u;
u.setUnitFlags(0x01);
REQUIRE(u.getUnitFlags() == 0x01);
u.setDynamicFlags(0x02);
REQUIRE(u.getDynamicFlags() == 0x02);
u.setNpcFlags(0x04);
REQUIRE(u.getNpcFlags() == 0x04);
REQUIRE(u.isInteractable());
u.setNpcFlags(0);
REQUIRE_FALSE(u.isInteractable());
}
TEST_CASE("Unit faction and hostility", "[entity]") {
Unit u;
u.setFactionTemplate(14); // Undercity
REQUIRE(u.getFactionTemplate() == 14);
REQUIRE_FALSE(u.isHostile());
u.setHostile(true);
REQUIRE(u.isHostile());
}
TEST_CASE("Unit mount display ID", "[entity]") {
Unit u;
REQUIRE(u.getMountDisplayId() == 0);
u.setMountDisplayId(14374);
REQUIRE(u.getMountDisplayId() == 14374);
}
TEST_CASE("Player inherits Unit", "[entity]") {
Player p(0xABC);
REQUIRE(p.getType() == ObjectType::PLAYER);
REQUIRE(p.getGuid() == 0xABC);
// Player inherits Unit name — regression test for the shadowed-field fix
p.setName("Arthas");
REQUIRE(p.getName() == "Arthas");
p.setLevel(80);
REQUIRE(p.getLevel() == 80);
}
TEST_CASE("GameObject construction", "[entity]") {
GameObject go(0x999);
REQUIRE(go.getType() == ObjectType::GAMEOBJECT);
REQUIRE(go.getGuid() == 0x999);
go.setName("Mailbox");
REQUIRE(go.getName() == "Mailbox");
go.setEntry(42);
REQUIRE(go.getEntry() == 42);
go.setDisplayId(100);
REQUIRE(go.getDisplayId() == 100);
}
TEST_CASE("EntityManager add/get/has/remove", "[entity]") {
EntityManager mgr;
REQUIRE(mgr.getEntityCount() == 0);
auto unit = std::make_shared<Unit>(1);
unit->setName("TestUnit");
mgr.addEntity(1, unit);
REQUIRE(mgr.getEntityCount() == 1);
REQUIRE(mgr.hasEntity(1));
REQUIRE_FALSE(mgr.hasEntity(2));
auto retrieved = mgr.getEntity(1);
REQUIRE(retrieved != nullptr);
REQUIRE(retrieved->getGuid() == 1);
mgr.removeEntity(1);
REQUIRE_FALSE(mgr.hasEntity(1));
REQUIRE(mgr.getEntityCount() == 0);
}
TEST_CASE("EntityManager clear", "[entity]") {
EntityManager mgr;
mgr.addEntity(1, std::make_shared<Entity>(1));
mgr.addEntity(2, std::make_shared<Entity>(2));
REQUIRE(mgr.getEntityCount() == 2);
mgr.clear();
REQUIRE(mgr.getEntityCount() == 0);
}
TEST_CASE("EntityManager null entity rejected", "[entity]") {
EntityManager mgr;
mgr.addEntity(1, nullptr);
// Null should be rejected (logged warning, not stored)
REQUIRE(mgr.getEntityCount() == 0);
}
TEST_CASE("EntityManager getEntities returns all", "[entity]") {
EntityManager mgr;
mgr.addEntity(10, std::make_shared<Unit>(10));
mgr.addEntity(20, std::make_shared<Player>(20));
mgr.addEntity(30, std::make_shared<GameObject>(30));
const auto& all = mgr.getEntities();
REQUIRE(all.size() == 3);
REQUIRE(all.count(10) == 1);
REQUIRE(all.count(20) == 1);
REQUIRE(all.count(30) == 1);
}

132
tests/test_frustum.cpp Normal file
View file

@ -0,0 +1,132 @@
// Phase 0 Frustum plane extraction and intersection tests
#include <catch_amalgamated.hpp>
#include "rendering/frustum.hpp"
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
using wowee::rendering::Frustum;
using wowee::rendering::Plane;
TEST_CASE("Plane distanceToPoint", "[frustum]") {
// Plane facing +Y at y=5
Plane p(glm::vec3(0.0f, 1.0f, 0.0f), -5.0f);
// Point at y=10 → distance = 10 + (-5) = 5 (in front)
REQUIRE(p.distanceToPoint(glm::vec3(0, 10, 0)) == Catch::Approx(5.0f));
// Point at y=5 → distance = 0 (on plane)
REQUIRE(p.distanceToPoint(glm::vec3(0, 5, 0)) == Catch::Approx(0.0f));
// Point at y=0 → distance = -5 (behind)
REQUIRE(p.distanceToPoint(glm::vec3(0, 0, 0)) == Catch::Approx(-5.0f));
}
TEST_CASE("Frustum extractFromMatrix with identity", "[frustum]") {
Frustum f;
f.extractFromMatrix(glm::mat4(1.0f));
// Identity matrix gives clip-space frustum: [-1,1]^3 (or [0,1] for z)
// The origin should be inside
REQUIRE(f.containsPoint(glm::vec3(0.0f, 0.0f, 0.5f)));
}
TEST_CASE("Frustum containsPoint perspective", "[frustum]") {
// Create a typical perspective projection and look-at
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 100.0f);
glm::mat4 view = glm::lookAt(
glm::vec3(0, 0, 0), // eye
glm::vec3(0, 0, -1), // center (looking -Z)
glm::vec3(0, 1, 0) // up
);
glm::mat4 vp = proj * view;
Frustum f;
f.extractFromMatrix(vp);
// Point in front (at -Z)
REQUIRE(f.containsPoint(glm::vec3(0, 0, -10)));
// Point behind camera (at +Z) should be outside
REQUIRE_FALSE(f.containsPoint(glm::vec3(0, 0, 10)));
// Point very far away inside the frustum
REQUIRE(f.containsPoint(glm::vec3(0, 0, -50)));
// Point beyond far plane
REQUIRE_FALSE(f.containsPoint(glm::vec3(0, 0, -200)));
}
TEST_CASE("Frustum intersectsSphere", "[frustum]") {
glm::mat4 proj = glm::perspective(glm::radians(60.0f), 1.0f, 1.0f, 100.0f);
glm::mat4 view = glm::lookAt(
glm::vec3(0, 0, 0),
glm::vec3(0, 0, -1),
glm::vec3(0, 1, 0)
);
Frustum f;
f.extractFromMatrix(proj * view);
// Sphere clearly inside
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, -10), 1.0f));
// Sphere behind camera
REQUIRE_FALSE(f.intersectsSphere(glm::vec3(0, 0, 50), 1.0f));
// Large sphere that straddles the near plane
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, 0), 5.0f));
// Sphere at edge of frustum — large radius should still intersect
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, -105), 10.0f));
}
TEST_CASE("Frustum intersectsAABB", "[frustum]") {
glm::mat4 proj = glm::perspective(glm::radians(60.0f), 1.0f, 1.0f, 100.0f);
glm::mat4 view = glm::lookAt(
glm::vec3(0, 0, 0),
glm::vec3(0, 0, -1),
glm::vec3(0, 1, 0)
);
Frustum f;
f.extractFromMatrix(proj * view);
// Box inside frustum
REQUIRE(f.intersectsAABB(glm::vec3(-1, -1, -11), glm::vec3(1, 1, -9)));
// Box behind camera
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(-1, -1, 5), glm::vec3(1, 1, 10)));
// Box beyond far plane
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(-1, -1, -200), glm::vec3(1, 1, -150)));
// Large box straddling near/far
REQUIRE(f.intersectsAABB(glm::vec3(-5, -5, -50), glm::vec3(5, 5, 0)));
}
TEST_CASE("Frustum getPlane returns 6 planes", "[frustum]") {
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 1.0f, 100.0f);
Frustum f;
f.extractFromMatrix(proj);
// Access all 6 planes — should not crash
for (int i = 0; i < 6; ++i) {
const auto& p = f.getPlane(static_cast<Frustum::Side>(i));
// Normal should be a unit vector (after normalization)
float len = glm::length(p.normal);
REQUIRE(len == Catch::Approx(1.0f).margin(0.01f));
}
}
TEST_CASE("Frustum box far right is outside", "[frustum]") {
glm::mat4 proj = glm::perspective(glm::radians(45.0f), 1.0f, 1.0f, 100.0f);
glm::mat4 view = glm::lookAt(
glm::vec3(0, 0, 0),
glm::vec3(0, 0, -1),
glm::vec3(0, 1, 0)
);
Frustum f;
f.extractFromMatrix(proj * view);
// Box far off to the right — outside the frustum
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(200, 0, -10), glm::vec3(201, 1, -9)));
}

164
tests/test_m2_structs.cpp Normal file
View file

@ -0,0 +1,164 @@
// Phase 0 M2 struct layout and field tests (header-only, no loader source)
#include <catch_amalgamated.hpp>
#include "pipeline/m2_loader.hpp"
#include <cstring>
using namespace wowee::pipeline;
TEST_CASE("M2Sequence fields are default-initialized", "[m2]") {
M2Sequence seq{};
REQUIRE(seq.id == 0);
REQUIRE(seq.duration == 0);
REQUIRE(seq.movingSpeed == 0.0f);
REQUIRE(seq.flags == 0);
REQUIRE(seq.blendTime == 0);
REQUIRE(seq.boundRadius == 0.0f);
}
TEST_CASE("M2AnimationTrack hasData", "[m2]") {
M2AnimationTrack track;
REQUIRE_FALSE(track.hasData());
track.sequences.push_back({});
REQUIRE(track.hasData());
}
TEST_CASE("M2AnimationTrack default interpolation", "[m2]") {
M2AnimationTrack track;
REQUIRE(track.interpolationType == 0);
REQUIRE(track.globalSequence == -1);
}
TEST_CASE("M2Bone parent defaults to root", "[m2]") {
M2Bone bone{};
bone.parentBone = -1;
REQUIRE(bone.parentBone == -1);
REQUIRE(bone.keyBoneId == 0);
}
TEST_CASE("M2Vertex layout", "[m2]") {
M2Vertex vert{};
vert.position = glm::vec3(1.0f, 2.0f, 3.0f);
vert.boneWeights[0] = 255;
vert.boneWeights[1] = 0;
vert.boneWeights[2] = 0;
vert.boneWeights[3] = 0;
vert.boneIndices[0] = 5;
vert.normal = glm::vec3(0.0f, 1.0f, 0.0f);
vert.texCoords[0] = glm::vec2(0.5f, 0.5f);
REQUIRE(vert.position.x == 1.0f);
REQUIRE(vert.boneWeights[0] == 255);
REQUIRE(vert.boneIndices[0] == 5);
REQUIRE(vert.normal.y == 1.0f);
REQUIRE(vert.texCoords[0].x == 0.5f);
}
TEST_CASE("M2Texture stores filename", "[m2]") {
M2Texture tex{};
tex.type = 1;
tex.filename = "Creature\\Hogger\\Hogger.blp";
REQUIRE(tex.filename == "Creature\\Hogger\\Hogger.blp");
}
TEST_CASE("M2Batch submesh fields", "[m2]") {
M2Batch batch{};
batch.skinSectionIndex = 3;
batch.textureCount = 2;
batch.indexStart = 100;
batch.indexCount = 300;
batch.vertexStart = 0;
batch.vertexCount = 150;
batch.submeshId = 0;
batch.submeshLevel = 0;
REQUIRE(batch.skinSectionIndex == 3);
REQUIRE(batch.textureCount == 2);
REQUIRE(batch.indexCount == 300);
REQUIRE(batch.vertexCount == 150);
}
TEST_CASE("M2Material blend modes", "[m2]") {
M2Material mat{};
mat.flags = 0;
mat.blendMode = 2; // Alpha blend
REQUIRE(mat.blendMode == 2);
mat.blendMode = 0; // Opaque
REQUIRE(mat.blendMode == 0);
}
TEST_CASE("M2Model isValid", "[m2]") {
M2Model model{};
REQUIRE_FALSE(model.isValid()); // no vertices or indices
model.vertices.push_back({});
REQUIRE_FALSE(model.isValid()); // vertices but no indices
model.indices.push_back(0);
REQUIRE(model.isValid()); // both present
}
TEST_CASE("M2Model bounding box", "[m2]") {
M2Model model{};
model.boundMin = glm::vec3(-1.0f, -2.0f, -3.0f);
model.boundMax = glm::vec3(1.0f, 2.0f, 3.0f);
model.boundRadius = 5.0f;
glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f;
REQUIRE(center.x == Catch::Approx(0.0f));
REQUIRE(center.y == Catch::Approx(0.0f));
REQUIRE(center.z == Catch::Approx(0.0f));
}
TEST_CASE("M2ParticleEmitter defaults", "[m2]") {
M2ParticleEmitter emitter{};
emitter.textureRows = 1;
emitter.textureCols = 1;
emitter.enabled = true;
REQUIRE(emitter.textureRows == 1);
REQUIRE(emitter.textureCols == 1);
REQUIRE(emitter.enabled);
}
TEST_CASE("M2RibbonEmitter defaults", "[m2]") {
M2RibbonEmitter ribbon{};
REQUIRE(ribbon.edgesPerSecond == Catch::Approx(15.0f));
REQUIRE(ribbon.edgeLifetime == Catch::Approx(0.5f));
REQUIRE(ribbon.gravity == Catch::Approx(0.0f));
}
TEST_CASE("M2Attachment position", "[m2]") {
M2Attachment att{};
att.id = 1; // Right hand
att.bone = 42;
att.position = glm::vec3(0.1f, 0.2f, 0.3f);
REQUIRE(att.id == 1);
REQUIRE(att.bone == 42);
REQUIRE(att.position.z == Catch::Approx(0.3f));
}
TEST_CASE("M2Model collections", "[m2]") {
M2Model model{};
// Bones
model.bones.push_back({});
model.bones[0].parentBone = -1;
model.bones[0].pivot = glm::vec3(0, 0, 0);
// Sequences
model.sequences.push_back({});
model.sequences[0].id = 0; // Stand
model.sequences[0].duration = 1000;
// Textures
model.textures.push_back({});
model.textures[0].type = 0;
model.textures[0].filename = "test.blp";
REQUIRE(model.bones.size() == 1);
REQUIRE(model.sequences.size() == 1);
REQUIRE(model.textures.size() == 1);
REQUIRE(model.sequences[0].duration == 1000);
}

118
tests/test_opcode_table.cpp Normal file
View file

@ -0,0 +1,118 @@
// Phase 0 OpcodeTable load from JSON, toWire/fromWire mapping
#include <catch_amalgamated.hpp>
#include "game/opcode_table.hpp"
#include <fstream>
#include <filesystem>
#include <cstdio>
using wowee::game::OpcodeTable;
using wowee::game::LogicalOpcode;
// Helper: write a temporary JSON file and return its path.
// Uses the executable's directory to avoid permission issues.
static std::string writeTempJson(const std::string& content) {
auto path = std::filesystem::temp_directory_path() / "wowee_test_opcodes.json";
std::ofstream f(path);
f << content;
f.close();
return path.string();
}
TEST_CASE("OpcodeTable loadFromJson basic mapping", "[opcode_table]") {
// CMSG_PING and SMSG_PONG are canonical opcodes present in the generated enum.
std::string json = R"({
"CMSG_PING": "0x1DC",
"SMSG_PONG": "0x1DD"
})";
auto path = writeTempJson(json);
OpcodeTable table;
REQUIRE(table.loadFromJson(path));
REQUIRE(table.size() == 2);
REQUIRE(table.hasOpcode(LogicalOpcode::CMSG_PING));
REQUIRE(table.toWire(LogicalOpcode::CMSG_PING) == 0x1DC);
REQUIRE(table.toWire(LogicalOpcode::SMSG_PONG) == 0x1DD);
std::remove(path.c_str());
}
TEST_CASE("OpcodeTable fromWire reverse lookup", "[opcode_table]") {
std::string json = R"({ "CMSG_PING": "0x1DC" })";
auto path = writeTempJson(json);
OpcodeTable table;
table.loadFromJson(path);
auto result = table.fromWire(0x1DC);
REQUIRE(result.has_value());
REQUIRE(*result == LogicalOpcode::CMSG_PING);
std::remove(path.c_str());
}
TEST_CASE("OpcodeTable unknown wire returns nullopt", "[opcode_table]") {
std::string json = R"({ "CMSG_PING": "0x1DC" })";
auto path = writeTempJson(json);
OpcodeTable table;
table.loadFromJson(path);
auto result = table.fromWire(0x9999);
REQUIRE_FALSE(result.has_value());
std::remove(path.c_str());
}
TEST_CASE("OpcodeTable unknown logical returns 0xFFFF", "[opcode_table]") {
std::string json = R"({ "CMSG_PING": "0x1DC" })";
auto path = writeTempJson(json);
OpcodeTable table;
table.loadFromJson(path);
// SMSG_AUTH_CHALLENGE should not be in this table
REQUIRE(table.toWire(LogicalOpcode::SMSG_AUTH_CHALLENGE) == 0xFFFF);
std::remove(path.c_str());
}
TEST_CASE("OpcodeTable loadFromJson nonexistent file", "[opcode_table]") {
OpcodeTable table;
REQUIRE_FALSE(table.loadFromJson("/nonexistent/path/opcodes.json"));
REQUIRE(table.size() == 0);
}
TEST_CASE("OpcodeTable logicalToName returns enum name", "[opcode_table]") {
const char* name = OpcodeTable::logicalToName(LogicalOpcode::CMSG_PING);
REQUIRE(name != nullptr);
REQUIRE(std::string(name) == "CMSG_PING");
}
TEST_CASE("OpcodeTable decimal wire values", "[opcode_table]") {
std::string json = R"({ "CMSG_PING": "476" })";
auto path = writeTempJson(json);
OpcodeTable table;
REQUIRE(table.loadFromJson(path));
REQUIRE(table.toWire(LogicalOpcode::CMSG_PING) == 476);
std::remove(path.c_str());
}
TEST_CASE("Global active opcode table", "[opcode_table]") {
OpcodeTable table;
std::string json = R"({ "CMSG_PING": "0x1DC" })";
auto path = writeTempJson(json);
table.loadFromJson(path);
wowee::game::setActiveOpcodeTable(&table);
REQUIRE(wowee::game::getActiveOpcodeTable() == &table);
REQUIRE(wowee::game::wireOpcode(LogicalOpcode::CMSG_PING) == 0x1DC);
// Reset
wowee::game::setActiveOpcodeTable(nullptr);
REQUIRE(wowee::game::wireOpcode(LogicalOpcode::CMSG_PING) == 0xFFFF);
std::remove(path.c_str());
}

192
tests/test_packet.cpp Normal file
View file

@ -0,0 +1,192 @@
// Phase 0 Packet read/write round-trip, packed GUID, bounds checks
#include <catch_amalgamated.hpp>
#include "network/packet.hpp"
using wowee::network::Packet;
TEST_CASE("Packet default constructor", "[packet]") {
Packet p;
REQUIRE(p.getOpcode() == 0);
REQUIRE(p.getSize() == 0);
REQUIRE(p.getReadPos() == 0);
REQUIRE(p.getRemainingSize() == 0);
REQUIRE_FALSE(p.hasData());
}
TEST_CASE("Packet opcode constructor", "[packet]") {
Packet p(0x1DC);
REQUIRE(p.getOpcode() == 0x1DC);
REQUIRE(p.getSize() == 0);
}
TEST_CASE("Packet write/read UInt8 round-trip", "[packet]") {
Packet p(1);
p.writeUInt8(0);
p.writeUInt8(127);
p.writeUInt8(255);
REQUIRE(p.getSize() == 3);
REQUIRE(p.readUInt8() == 0);
REQUIRE(p.readUInt8() == 127);
REQUIRE(p.readUInt8() == 255);
REQUIRE_FALSE(p.hasData());
}
TEST_CASE("Packet write/read UInt16 round-trip", "[packet]") {
Packet p(1);
p.writeUInt16(0);
p.writeUInt16(0xBEEF);
p.writeUInt16(0xFFFF);
REQUIRE(p.getSize() == 6);
REQUIRE(p.readUInt16() == 0);
REQUIRE(p.readUInt16() == 0xBEEF);
REQUIRE(p.readUInt16() == 0xFFFF);
}
TEST_CASE("Packet write/read UInt32 round-trip", "[packet]") {
Packet p(1);
p.writeUInt32(0xDEADBEEF);
REQUIRE(p.readUInt32() == 0xDEADBEEF);
}
TEST_CASE("Packet write/read UInt64 round-trip", "[packet]") {
Packet p(1);
p.writeUInt64(0x0123456789ABCDEFULL);
REQUIRE(p.readUInt64() == 0x0123456789ABCDEFULL);
}
TEST_CASE("Packet write/read float round-trip", "[packet]") {
Packet p(1);
p.writeFloat(3.14f);
p.writeFloat(-0.0f);
p.writeFloat(1e10f);
REQUIRE(p.readFloat() == Catch::Approx(3.14f));
REQUIRE(p.readFloat() == -0.0f);
REQUIRE(p.readFloat() == Catch::Approx(1e10f));
}
TEST_CASE("Packet write/read string round-trip", "[packet]") {
Packet p(1);
p.writeString("Hello WoW");
p.writeString(""); // empty string
REQUIRE(p.readString() == "Hello WoW");
REQUIRE(p.readString() == "");
}
TEST_CASE("Packet writeBytes / readUInt8 array", "[packet]") {
Packet p(1);
const uint8_t buf[] = {0xAA, 0xBB, 0xCC};
p.writeBytes(buf, 3);
REQUIRE(p.getSize() == 3);
REQUIRE(p.readUInt8() == 0xAA);
REQUIRE(p.readUInt8() == 0xBB);
REQUIRE(p.readUInt8() == 0xCC);
}
TEST_CASE("Packet packed GUID round-trip", "[packet]") {
SECTION("Zero GUID") {
Packet p(1);
p.writePackedGuid(0);
REQUIRE(p.hasFullPackedGuid());
REQUIRE(p.readPackedGuid() == 0);
}
SECTION("Low GUID (single byte)") {
Packet p(1);
p.writePackedGuid(0x42);
REQUIRE(p.readPackedGuid() == 0x42);
}
SECTION("Full 64-bit GUID") {
Packet p(1);
uint64_t guid = 0x0102030405060708ULL;
p.writePackedGuid(guid);
REQUIRE(p.readPackedGuid() == guid);
}
SECTION("Max GUID") {
Packet p(1);
uint64_t guid = 0xFFFFFFFFFFFFFFFFULL;
p.writePackedGuid(guid);
REQUIRE(p.readPackedGuid() == guid);
}
}
TEST_CASE("Packet getRemainingSize and hasRemaining", "[packet]") {
Packet p(1);
p.writeUInt32(100);
p.writeUInt32(200);
REQUIRE(p.getRemainingSize() == 8);
REQUIRE(p.hasRemaining(8));
REQUIRE_FALSE(p.hasRemaining(9));
p.readUInt32();
REQUIRE(p.getRemainingSize() == 4);
REQUIRE(p.hasRemaining(4));
REQUIRE_FALSE(p.hasRemaining(5));
p.readUInt32();
REQUIRE(p.getRemainingSize() == 0);
REQUIRE(p.hasRemaining(0));
REQUIRE_FALSE(p.hasRemaining(1));
}
TEST_CASE("Packet setReadPos and skipAll", "[packet]") {
Packet p(1);
p.writeUInt8(10);
p.writeUInt8(20);
p.writeUInt8(30);
p.readUInt8(); // pos = 1
p.setReadPos(0);
REQUIRE(p.readUInt8() == 10);
p.skipAll();
REQUIRE(p.getRemainingSize() == 0);
REQUIRE_FALSE(p.hasData());
}
TEST_CASE("Packet constructed with data vector", "[packet]") {
std::vector<uint8_t> raw = {0x01, 0x02, 0x03};
Packet p(42, raw);
REQUIRE(p.getOpcode() == 42);
REQUIRE(p.getSize() == 3);
REQUIRE(p.readUInt8() == 0x01);
}
TEST_CASE("Packet constructed with rvalue data", "[packet]") {
Packet p(99, std::vector<uint8_t>{0xFF, 0xFE});
REQUIRE(p.getSize() == 2);
REQUIRE(p.readUInt8() == 0xFF);
REQUIRE(p.readUInt8() == 0xFE);
}
TEST_CASE("Packet mixed types interleaved", "[packet]") {
Packet p(1);
p.writeUInt8(0xAA);
p.writeUInt32(0xDEADBEEF);
p.writeString("test");
p.writeFloat(2.5f);
p.writeUInt16(0x1234);
REQUIRE(p.readUInt8() == 0xAA);
REQUIRE(p.readUInt32() == 0xDEADBEEF);
REQUIRE(p.readString() == "test");
REQUIRE(p.readFloat() == Catch::Approx(2.5f));
REQUIRE(p.readUInt16() == 0x1234);
REQUIRE_FALSE(p.hasData());
}
TEST_CASE("Packet hasFullPackedGuid returns false on empty", "[packet]") {
Packet p(1);
REQUIRE_FALSE(p.hasFullPackedGuid());
}
TEST_CASE("Packet getRemainingSize clamps after overshoot", "[packet]") {
Packet p(1);
p.writeUInt8(1);
p.setReadPos(999);
REQUIRE(p.getRemainingSize() == 0);
REQUIRE_FALSE(p.hasRemaining(1));
}

127
tests/test_srp.cpp Normal file
View file

@ -0,0 +1,127 @@
// Phase 0 SRP6a challenge/proof smoke tests
#include <catch_amalgamated.hpp>
#include "auth/srp.hpp"
#include "auth/crypto.hpp"
using wowee::auth::SRP;
using wowee::auth::Crypto;
// WoW 3.3.5a uses well-known SRP6a parameters.
// Generator g = 7, N = a large 32-byte safe prime.
// We use the canonical WoW values for integration-level tests.
static const std::vector<uint8_t> kWoWGenerator = { 7 };
// WoW's 32-byte large safe prime (little-endian)
static const std::vector<uint8_t> kWoWPrime = {
0xB7, 0x9B, 0x3E, 0x2A, 0x87, 0x82, 0x3C, 0xAB,
0x8F, 0x5E, 0xBF, 0xBF, 0x8E, 0xB1, 0x01, 0x08,
0x53, 0x50, 0x06, 0x29, 0x8B, 0x5B, 0xAD, 0xBD,
0x5B, 0x53, 0xE1, 0x89, 0x5E, 0x64, 0x4B, 0x89
};
TEST_CASE("SRP initialize stores credentials", "[srp]") {
SRP srp;
// Should not throw
REQUIRE_NOTHROW(srp.initialize("TEST", "PASSWORD"));
}
TEST_CASE("SRP initializeWithHash accepts pre-computed hash", "[srp]") {
// Pre-compute SHA1("TEST:PASSWORD")
auto hash = Crypto::sha1(std::string("TEST:PASSWORD"));
REQUIRE(hash.size() == 20);
SRP srp;
REQUIRE_NOTHROW(srp.initializeWithHash("TEST", hash));
}
TEST_CASE("SRP feed produces A and M1 of correct sizes", "[srp]") {
SRP srp;
srp.initialize("TEST", "PASSWORD");
// Fabricate a server B (32 bytes, non-zero to avoid SRP abort)
std::vector<uint8_t> B(32, 0);
B[0] = 0x42; // Non-zero
std::vector<uint8_t> salt(32, 0xAA);
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
auto A = srp.getA();
auto M1 = srp.getM1();
auto K = srp.getSessionKey();
// A should be 32 bytes (same size as N)
REQUIRE(A.size() == 32);
// M1 is SHA1 → 20 bytes
REQUIRE(M1.size() == 20);
// K is the interleaved session key → 40 bytes
REQUIRE(K.size() == 40);
}
TEST_CASE("SRP A is non-zero", "[srp]") {
SRP srp;
srp.initialize("PLAYER", "SECRET");
std::vector<uint8_t> B(32, 0);
B[3] = 0x01;
std::vector<uint8_t> salt(32, 0xBB);
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
auto A = srp.getA();
bool allZero = true;
for (auto b : A) {
if (b != 0) { allZero = false; break; }
}
REQUIRE_FALSE(allZero);
}
TEST_CASE("SRP different passwords produce different M1", "[srp]") {
auto runSrp = [](const std::string& pass) {
SRP srp;
srp.initialize("TESTUSER", pass);
std::vector<uint8_t> B(32, 0);
B[0] = 0x11;
std::vector<uint8_t> salt(32, 0xCC);
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
return srp.getM1();
};
auto m1a = runSrp("PASSWORD1");
auto m1b = runSrp("PASSWORD2");
REQUIRE(m1a != m1b);
}
TEST_CASE("SRP verifyServerProof rejects wrong proof", "[srp]") {
SRP srp;
srp.initialize("TEST", "PASSWORD");
std::vector<uint8_t> B(32, 0);
B[0] = 0x55;
std::vector<uint8_t> salt(32, 0xDD);
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
// Random 20 bytes should not match the expected M2
std::vector<uint8_t> fakeM2(20, 0xFF);
REQUIRE_FALSE(srp.verifyServerProof(fakeM2));
}
TEST_CASE("SRP setUseHashedK changes behavior", "[srp]") {
auto runWithHashedK = [](bool useHashed) {
SRP srp;
srp.setUseHashedK(useHashed);
srp.initialize("TEST", "PASSWORD");
std::vector<uint8_t> B(32, 0);
B[0] = 0x22;
std::vector<uint8_t> salt(32, 0xEE);
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
return srp.getM1();
};
auto m1_default = runWithHashedK(false);
auto m1_hashed = runWithHashedK(true);
// Different k derivation → different M1
REQUIRE(m1_default != m1_hashed);
}