refactor: decompose world map into modular component architecture

Break the monolithic 1360-line world_map.cpp into 16 focused modules
under src/rendering/world_map/:

Architecture:
- world_map_facade: public API composing all components (PIMPL)
- world_map_types: Vulkan-free domain types (Zone, ViewLevel, etc.)
- data_repository: DBC zone loading, ZMP pixel map, POI/overlay storage
- coordinate_projection: UV projection, zone/continent lookups
- composite_renderer: Vulkan tile pipeline + off-screen compositing
- exploration_state: server mask + local exploration tracking
- view_state_machine: COSMIC→WORLD→CONTINENT→ZONE navigation
- input_handler: keyboard/mouse input → InputAction mapping
- overlay_renderer: layer-based ImGui overlay system (OCP)
- map_resolver: cross-map navigation (Outland, Northrend, etc.)
- zone_metadata: level ranges and faction data

Overlay layers (each an IOverlayLayer):
- player_marker, party_dot, taxi_node, poi_marker, quest_poi,
  corpse_marker, zone_highlight, coordinate_display, subzone_tooltip

Fixes:
- Player marker no longer bleeds across continents (only shown when
  player is in a zone belonging to the displayed continent)
- Zone hover uses DBC-projected AABB rectangles (restored from
  original working behavior)
- Exploration overlay rendering for zone view subzones

Tests:
- 6 new test files covering coordinate projection, exploration state,
  map resolver, view state machine, zone metadata, and integration

Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
This commit is contained in:
Pavel Okhlopkov 2026-04-12 09:52:51 +03:00
parent db3f65a87e
commit fff06fc932
55 changed files with 6335 additions and 1542 deletions

View file

@ -263,6 +263,92 @@ endif()
add_test(NAME transport_components COMMAND test_transport_components)
register_test_target(test_transport_components)
# ── test_world_map ────────────────────────────────────────────
add_executable(test_world_map
test_world_map.cpp
)
target_include_directories(test_world_map PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map PRIVATE glm::glm)
endif()
add_test(NAME world_map COMMAND test_world_map)
register_test_target(test_world_map)
# ── test_world_map_coordinate_projection ──────────────────────
add_executable(test_world_map_coordinate_projection
test_world_map_coordinate_projection.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
)
target_include_directories(test_world_map_coordinate_projection PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_coordinate_projection SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_coordinate_projection PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_coordinate_projection PRIVATE glm::glm)
endif()
add_test(NAME world_map_coordinate_projection COMMAND test_world_map_coordinate_projection)
register_test_target(test_world_map_coordinate_projection)
# ── test_world_map_map_resolver ───────────────────────────────
add_executable(test_world_map_map_resolver
test_world_map_map_resolver.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/map_resolver.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
${TEST_COMMON_SOURCES}
)
target_include_directories(test_world_map_map_resolver PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_map_resolver SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_map_resolver PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_map_resolver PRIVATE glm::glm)
endif()
add_test(NAME world_map_map_resolver COMMAND test_world_map_map_resolver)
register_test_target(test_world_map_map_resolver)
# ── test_world_map_view_state_machine ─────────────────────────
add_executable(test_world_map_view_state_machine
test_world_map_view_state_machine.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/view_state_machine.cpp
)
target_include_directories(test_world_map_view_state_machine PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_view_state_machine SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_view_state_machine PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_view_state_machine PRIVATE glm::glm)
endif()
add_test(NAME world_map_view_state_machine COMMAND test_world_map_view_state_machine)
register_test_target(test_world_map_view_state_machine)
# ── test_world_map_exploration_state ──────────────────────────
add_executable(test_world_map_exploration_state
test_world_map_exploration_state.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/exploration_state.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/coordinate_projection.cpp
)
target_include_directories(test_world_map_exploration_state PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_exploration_state SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_exploration_state PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_exploration_state PRIVATE glm::glm)
endif()
add_test(NAME world_map_exploration_state COMMAND test_world_map_exploration_state)
register_test_target(test_world_map_exploration_state)
# ── test_world_map_zone_metadata ──────────────────────────────
add_executable(test_world_map_zone_metadata
test_world_map_zone_metadata.cpp
${CMAKE_SOURCE_DIR}/src/rendering/world_map/zone_metadata.cpp
)
target_include_directories(test_world_map_zone_metadata PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_world_map_zone_metadata SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_world_map_zone_metadata PRIVATE catch2_main)
if(TARGET glm::glm)
target_link_libraries(test_world_map_zone_metadata PRIVATE glm::glm)
endif()
add_test(NAME world_map_zone_metadata COMMAND test_world_map_zone_metadata)
register_test_target(test_world_map_zone_metadata)
# ── ASAN / UBSan for test targets ────────────────────────────
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
foreach(_t IN LISTS ALL_TEST_TARGETS)

498
tests/test_world_map.cpp Normal file
View file

@ -0,0 +1,498 @@
// Tests for WorldMap data structures and coordinate math
// Updated to use new modular types from world_map_types.hpp
#include <catch_amalgamated.hpp>
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <cmath>
#include <algorithm>
#include <array>
#include <vector>
#include <string>
using wowee::rendering::world_map::Zone;
using wowee::rendering::world_map::OverlayEntry;
using wowee::rendering::world_map::POI;
// ── MapPOI struct ────────────────────────────────────────────
TEST_CASE("POI default-constructed is zeroed", "[world_map]") {
POI poi{};
REQUIRE(poi.id == 0);
REQUIRE(poi.importance == 0);
REQUIRE(poi.iconType == 0);
REQUIRE(poi.factionId == 0);
REQUIRE(poi.wowX == 0.0f);
REQUIRE(poi.wowY == 0.0f);
REQUIRE(poi.wowZ == 0.0f);
REQUIRE(poi.mapId == 0);
REQUIRE(poi.name.empty());
REQUIRE(poi.description.empty());
}
TEST_CASE("POI sorts by importance ascending", "[world_map]") {
std::vector<POI> pois;
POI capital;
capital.id = 1;
capital.importance = 3;
capital.name = "Stormwind";
pois.push_back(capital);
POI town;
town.id = 2;
town.importance = 1;
town.name = "Goldshire";
pois.push_back(town);
POI minor;
minor.id = 3;
minor.importance = 0;
minor.name = "Mirror Lake";
pois.push_back(minor);
std::sort(pois.begin(), pois.end(), [](const POI& a, const POI& b) {
return a.importance < b.importance;
});
REQUIRE(pois[0].name == "Mirror Lake");
REQUIRE(pois[1].name == "Goldshire");
REQUIRE(pois[2].name == "Stormwind");
}
// ── WorldMapZone struct ──────────────────────────────────────
TEST_CASE("Zone default-constructed is valid", "[world_map]") {
Zone z{};
REQUIRE(z.wmaID == 0);
REQUIRE(z.areaID == 0);
REQUIRE(z.areaName.empty());
REQUIRE(z.bounds.locLeft == 0.0f);
REQUIRE(z.bounds.locRight == 0.0f);
REQUIRE(z.bounds.locTop == 0.0f);
REQUIRE(z.bounds.locBottom == 0.0f);
REQUIRE(z.displayMapID == 0);
REQUIRE(z.parentWorldMapID == 0);
REQUIRE(z.exploreBits.empty());
}
TEST_CASE("Zone areaID==0 identifies continent", "[world_map]") {
Zone continent{};
continent.areaID = 0;
continent.wmaID = 10;
continent.areaName = "Kalimdor";
Zone zone{};
zone.areaID = 440;
zone.wmaID = 100;
zone.areaName = "Tanaris";
REQUIRE(continent.areaID == 0);
REQUIRE(zone.areaID != 0);
}
// ── Coordinate projection logic ──────────────────────────────
// Replicate the UV projection formula from renderPosToMapUV for standalone testing.
static glm::vec2 computeMapUV(float wowX, float wowY,
float locLeft, float locRight,
float locTop, float locBottom,
bool isContinent) {
float denom_h = locLeft - locRight;
float denom_v = locTop - locBottom;
if (std::abs(denom_h) < 0.001f || std::abs(denom_v) < 0.001f)
return glm::vec2(0.5f, 0.5f);
float u = (locLeft - wowX) / denom_h;
float v = (locTop - wowY) / denom_v;
if (isContinent) {
constexpr float kVOffset = -0.15f;
v = (v - 0.5f) + 0.5f + kVOffset;
}
return glm::vec2(u, v);
}
TEST_CASE("UV projection: center of zone maps to (0.5, 0.5)", "[world_map]") {
// Zone bounds: left=1000, right=0, top=1000, bottom=0
float centerX = 500.0f, centerY = 500.0f;
glm::vec2 uv = computeMapUV(centerX, centerY, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.5f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(0.5f).margin(0.001f));
}
TEST_CASE("UV projection: top-left corner maps to (0, 0)", "[world_map]") {
glm::vec2 uv = computeMapUV(1000.0f, 1000.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.0f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(0.0f).margin(0.001f));
}
TEST_CASE("UV projection: bottom-right corner maps to (1, 1)", "[world_map]") {
glm::vec2 uv = computeMapUV(0.0f, 0.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(1.0f).margin(0.001f));
REQUIRE(uv.y == Catch::Approx(1.0f).margin(0.001f));
}
TEST_CASE("UV projection: degenerate bounds returns center", "[world_map]") {
// left == right → degenerate
glm::vec2 uv = computeMapUV(500.0f, 500.0f, 500.0f, 500.0f, 1000.0f, 0.0f, false);
REQUIRE(uv.x == Catch::Approx(0.5f));
REQUIRE(uv.y == Catch::Approx(0.5f));
}
TEST_CASE("UV projection: continent mode applies vertical offset", "[world_map]") {
// Same center point, but continent mode shifts V by kVOffset=-0.15
glm::vec2 uvZone = computeMapUV(500.0f, 500.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, false);
glm::vec2 uvCont = computeMapUV(500.0f, 500.0f, 1000.0f, 0.0f, 1000.0f, 0.0f, true);
REQUIRE(uvZone.x == Catch::Approx(uvCont.x).margin(0.001f));
// Continent V should be shifted by -0.15
REQUIRE(uvCont.y == Catch::Approx(uvZone.y - 0.15f).margin(0.001f));
}
// ── Expansion level derivation ───────────────────────────────
// Replicate the expansion detection logic from getExpansionLevel.
static int deriveExpansionLevel(int maxLevel) {
if (maxLevel <= 60) return 0; // vanilla
if (maxLevel <= 70) return 1; // TBC
return 2; // WotLK
}
TEST_CASE("Expansion level from maxLevel", "[world_map]") {
REQUIRE(deriveExpansionLevel(60) == 0); // vanilla
REQUIRE(deriveExpansionLevel(58) == 0); // below vanilla cap
REQUIRE(deriveExpansionLevel(70) == 1); // TBC
REQUIRE(deriveExpansionLevel(65) == 1); // mid TBC range
REQUIRE(deriveExpansionLevel(80) == 2); // WotLK
REQUIRE(deriveExpansionLevel(75) == 2); // mid WotLK range
}
// ── Expansion continent filtering ────────────────────────────
static std::vector<uint32_t> filterContinentsByExpansion(
const std::vector<uint32_t>& mapIds, int expansionLevel) {
std::vector<uint32_t> result;
for (uint32_t id : mapIds) {
if (id == 530 && expansionLevel < 1) continue;
if (id == 571 && expansionLevel < 2) continue;
result.push_back(id);
}
return result;
}
TEST_CASE("Vanilla hides TBC and WotLK continents", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 0);
REQUIRE(filtered.size() == 2);
REQUIRE(filtered[0] == 0);
REQUIRE(filtered[1] == 1);
}
TEST_CASE("TBC shows Outland but hides Northrend", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 1);
REQUIRE(filtered.size() == 3);
REQUIRE(filtered[2] == 530);
}
TEST_CASE("WotLK shows all continents", "[world_map]") {
std::vector<uint32_t> all = {0, 1, 530, 571};
auto filtered = filterContinentsByExpansion(all, 2);
REQUIRE(filtered.size() == 4);
}
// ── POI faction coloring logic ───────────────────────────────
enum class Faction { Alliance, Horde, Neutral };
static Faction classifyFaction(uint32_t factionId) {
if (factionId == 469) return Faction::Alliance;
if (factionId == 67) return Faction::Horde;
return Faction::Neutral;
}
TEST_CASE("POI faction classification", "[world_map]") {
REQUIRE(classifyFaction(469) == Faction::Alliance);
REQUIRE(classifyFaction(67) == Faction::Horde);
REQUIRE(classifyFaction(0) == Faction::Neutral);
REQUIRE(classifyFaction(35) == Faction::Neutral);
}
// ── Overlay entry defaults ───────────────────────────────────
TEST_CASE("OverlayEntry defaults", "[world_map]") {
OverlayEntry ov{};
for (int i = 0; i < 4; i++) {
REQUIRE(ov.areaIDs[i] == 0);
}
REQUIRE(ov.textureName.empty());
REQUIRE(ov.texWidth == 0);
REQUIRE(ov.texHeight == 0);
REQUIRE(ov.offsetX == 0);
REQUIRE(ov.offsetY == 0);
REQUIRE(ov.hitRectLeft == 0);
REQUIRE(ov.hitRectRight == 0);
REQUIRE(ov.hitRectTop == 0);
REQUIRE(ov.hitRectBottom == 0);
REQUIRE(ov.tileCols == 0);
REQUIRE(ov.tileRows == 0);
REQUIRE(ov.tilesLoaded == false);
}
// ── ZMP pixel-map zone lookup ────────────────────────────────
TEST_CASE("ZMP grid lookup resolves mouse UV to zone", "[world_map]") {
// Simulate a 128x128 ZMP grid with a zone at a known cell
std::array<uint32_t, 128 * 128> grid{};
uint32_t testAreaId = 42;
// Place area ID at grid cell (64, 64) — center of map
grid[64 * 128 + 64] = testAreaId;
// Mouse at UV (0.5, 0.5) → col=64, row=64
float mu = 0.5f, mv = 0.5f;
constexpr int ZMP_SIZE = 128;
int col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
int row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
uint32_t areaId = grid[row * ZMP_SIZE + col];
REQUIRE(areaId == testAreaId);
}
TEST_CASE("ZMP grid returns 0 for empty cells", "[world_map]") {
std::array<uint32_t, 128 * 128> grid{};
// Empty grid — all cells zero (ocean/no zone)
constexpr int ZMP_SIZE = 128;
int col = 10, row = 10;
REQUIRE(grid[row * ZMP_SIZE + col] == 0);
}
TEST_CASE("ZMP grid clamps out-of-range UV", "[world_map]") {
std::array<uint32_t, 128 * 128> grid{};
grid[0] = 100; // (0,0) cell
grid[127 * 128 + 127] = 200; // (127,127) cell
constexpr int ZMP_SIZE = 128;
// UV at (-0.1, -0.1) should clamp to (0, 0)
float mu = -0.1f, mv = -0.1f;
int col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
int row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
REQUIRE(grid[row * ZMP_SIZE + col] == 100);
// UV at (1.5, 1.5) should clamp to (127, 127)
mu = 1.5f; mv = 1.5f;
col = std::clamp(static_cast<int>(mu * ZMP_SIZE), 0, ZMP_SIZE - 1);
row = std::clamp(static_cast<int>(mv * ZMP_SIZE), 0, ZMP_SIZE - 1);
REQUIRE(grid[row * ZMP_SIZE + col] == 200);
}
// ── HitRect overlay AABB pre-filter ──────────────────────────
TEST_CASE("HitRect filters overlays correctly", "[world_map]") {
OverlayEntry ov{};
ov.hitRectLeft = 100;
ov.hitRectRight = 300;
ov.hitRectTop = 50;
ov.hitRectBottom = 200;
ov.texWidth = 200;
ov.texHeight = 150;
ov.textureName = "Goldshire";
bool hasHitRect = (ov.hitRectRight > ov.hitRectLeft &&
ov.hitRectBottom > ov.hitRectTop);
REQUIRE(hasHitRect);
// Point inside HitRect
float px = 150.0f, py = 100.0f;
bool inside = (px >= ov.hitRectLeft && px <= ov.hitRectRight &&
py >= ov.hitRectTop && py <= ov.hitRectBottom);
REQUIRE(inside);
// Point outside HitRect
px = 50.0f; py = 25.0f;
inside = (px >= ov.hitRectLeft && px <= ov.hitRectRight &&
py >= ov.hitRectTop && py <= ov.hitRectBottom);
REQUIRE_FALSE(inside);
}
TEST_CASE("HitRect with zero values falls back to offset AABB", "[world_map]") {
OverlayEntry ov{};
// HitRect fields all zero → hasHitRect should be false
bool hasHitRect = (ov.hitRectRight > ov.hitRectLeft &&
ov.hitRectBottom > ov.hitRectTop);
REQUIRE_FALSE(hasHitRect);
}
TEST_CASE("Subzone hover with HitRect picks smallest overlay", "[world_map]") {
// Simulate two overlays — one large with HitRect, one small with HitRect
struct TestHitOverlay {
float hitLeft, hitRight, hitTop, hitBottom;
float texW, texH;
std::string name;
};
TestHitOverlay large{0.0f, 500.0f, 0.0f, 400.0f, 500.0f, 400.0f, "BigArea"};
TestHitOverlay small{100.0f, 250.0f, 80.0f, 180.0f, 150.0f, 100.0f, "SmallArea"};
std::vector<TestHitOverlay> overlays = {large, small};
float px = 150.0f, py = 120.0f; // Inside both HitRects
std::string best;
float bestArea = std::numeric_limits<float>::max();
for (const auto& ov : overlays) {
bool inside = (px >= ov.hitLeft && px <= ov.hitRight &&
py >= ov.hitTop && py <= ov.hitBottom);
if (inside) {
float area = ov.texW * ov.texH;
if (area < bestArea) {
bestArea = area;
best = ov.name;
}
}
}
REQUIRE(best == "SmallArea");
}
// ── Cosmic view expansion logic ──────────────────────────────
struct CosmicMapEntry {
int mapId = 0;
std::string label;
};
static std::vector<CosmicMapEntry> buildCosmicMaps(int expLevel) {
std::vector<CosmicMapEntry> maps;
if (expLevel == 0) return maps; // Vanilla: no cosmic
maps.push_back({0, "Azeroth"});
if (expLevel >= 1) maps.push_back({530, "Outland"});
if (expLevel >= 2) maps.push_back({571, "Northrend"});
return maps;
}
TEST_CASE("Vanilla has no cosmic view entries", "[world_map]") {
auto maps = buildCosmicMaps(0);
REQUIRE(maps.empty());
}
TEST_CASE("TBC cosmic view has Azeroth + Outland", "[world_map]") {
auto maps = buildCosmicMaps(1);
REQUIRE(maps.size() == 2);
REQUIRE(maps[0].mapId == 0);
REQUIRE(maps[0].label == "Azeroth");
REQUIRE(maps[1].mapId == 530);
REQUIRE(maps[1].label == "Outland");
}
TEST_CASE("WotLK cosmic view has all three worlds", "[world_map]") {
auto maps = buildCosmicMaps(2);
REQUIRE(maps.size() == 3);
REQUIRE(maps[2].mapId == 571);
REQUIRE(maps[2].label == "Northrend");
}
// ── Subzone hover priority (smallest overlay wins) ───────────
struct TestOverlay {
float offsetX, offsetY;
float width, height;
std::string name;
};
static std::string findSmallestOverlay(const std::vector<TestOverlay>& overlays,
float mu, float mv, float fboW, float fboH) {
std::string best;
float bestArea = std::numeric_limits<float>::max();
for (const auto& ov : overlays) {
float ovLeft = ov.offsetX / fboW;
float ovTop = ov.offsetY / fboH;
float ovRight = (ov.offsetX + ov.width) / fboW;
float ovBottom = (ov.offsetY + ov.height) / fboH;
if (mu >= ovLeft && mu <= ovRight && mv >= ovTop && mv <= ovBottom) {
float area = ov.width * ov.height;
if (area < bestArea) {
bestArea = area;
best = ov.name;
}
}
}
return best;
}
TEST_CASE("Subzone hover returns smallest overlapping overlay", "[world_map]") {
// Large overlay covers 0-512, 0-512
TestOverlay large{0.0f, 0.0f, 512.0f, 512.0f, "BigZone"};
// Small overlay covers 100-200, 100-200
TestOverlay small{100.0f, 100.0f, 100.0f, 100.0f, "SmallSubzone"};
std::vector<TestOverlay> overlays = {large, small};
// Mouse at UV (0.15, 0.15) → pixel (153.6, 115.2) for 1024x768 FBO
// Both overlays overlap; small should win
std::string result = findSmallestOverlay(overlays, 0.15f, 0.15f, 1024.0f, 768.0f);
REQUIRE(result == "SmallSubzone");
}
TEST_CASE("Subzone hover returns only overlay when one matches", "[world_map]") {
TestOverlay large{0.0f, 0.0f, 512.0f, 512.0f, "BigZone"};
TestOverlay small{100.0f, 100.0f, 100.0f, 100.0f, "SmallSubzone"};
std::vector<TestOverlay> overlays = {large, small};
// Mouse at UV (0.01, 0.01) → only large matches
std::string result = findSmallestOverlay(overlays, 0.01f, 0.01f, 1024.0f, 768.0f);
REQUIRE(result == "BigZone");
}
TEST_CASE("Subzone hover returns empty when nothing matches", "[world_map]") {
TestOverlay ov{100.0f, 100.0f, 50.0f, 50.0f, "Tiny"};
std::vector<TestOverlay> overlays = {ov};
std::string result = findSmallestOverlay(overlays, 0.01f, 0.01f, 1024.0f, 768.0f);
REQUIRE(result.empty());
}
// ── Zone metadata (level range + faction) ────────────────────
enum class TestFaction { Neutral, Alliance, Horde, Contested };
struct TestZoneMeta {
uint8_t minLevel = 0, maxLevel = 0;
TestFaction faction = TestFaction::Neutral;
};
static std::string formatZoneLabel(const std::string& name, const TestZoneMeta* meta) {
std::string label = name;
if (meta) {
if (meta->minLevel > 0 && meta->maxLevel > 0) {
label += " (" + std::to_string(meta->minLevel) + "-" +
std::to_string(meta->maxLevel) + ")";
}
switch (meta->faction) {
case TestFaction::Alliance: label += " [Alliance]"; break;
case TestFaction::Horde: label += " [Horde]"; break;
case TestFaction::Contested: label += " [Contested]"; break;
default: break;
}
}
return label;
}
TEST_CASE("Zone label includes level range and faction", "[world_map]") {
TestZoneMeta meta{1, 10, TestFaction::Alliance};
std::string label = formatZoneLabel("Elwynn", &meta);
REQUIRE(label == "Elwynn (1-10) [Alliance]");
}
TEST_CASE("Zone label shows Contested faction", "[world_map]") {
TestZoneMeta meta{30, 45, TestFaction::Contested};
std::string label = formatZoneLabel("Stranglethorn", &meta);
REQUIRE(label == "Stranglethorn (30-45) [Contested]");
}
TEST_CASE("Zone label without metadata is just the name", "[world_map]") {
std::string label = formatZoneLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("Zone label with Neutral faction omits tag", "[world_map]") {
TestZoneMeta meta{55, 60, TestFaction::Neutral};
std::string label = formatZoneLabel("Moonglade", &meta);
REQUIRE(label == "Moonglade (55-60)");
}

View file

@ -0,0 +1,193 @@
// Tests for the extracted world map coordinate projection module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/coordinate_projection.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <cmath>
#include <vector>
using namespace wowee::rendering::world_map;
// ── Helper: build a minimal zone for testing ─────────────────
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
float locLeft, float locRight,
float locTop, float locBottom,
uint32_t displayMapID = 0,
uint32_t parentWorldMapID = 0,
const std::string& name = "") {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.areaName = name;
z.bounds.locLeft = locLeft;
z.bounds.locRight = locRight;
z.bounds.locTop = locTop;
z.bounds.locBottom = locBottom;
z.displayMapID = displayMapID;
z.parentWorldMapID = parentWorldMapID;
return z;
}
// ── renderPosToMapUV ─────────────────────────────────────────
TEST_CASE("renderPosToMapUV: center of zone maps to (0.5, ~0.5)", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
// renderPos.y = wowX, renderPos.x = wowY
glm::vec3 center(0.0f, 0.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(center, bounds, /*isContinent=*/false);
REQUIRE(std::abs(uv.x - 0.5f) < 0.01f);
REQUIRE(std::abs(uv.y - 0.5f) < 0.01f);
}
TEST_CASE("renderPosToMapUV: degenerate bounds returns (0.5, 0.5)", "[world_map][coordinate_projection]") {
ZoneBounds bounds{}; // all zeros
glm::vec3 pos(100.0f, 200.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(pos, bounds, false);
REQUIRE(uv.x == Catch::Approx(0.5f));
REQUIRE(uv.y == Catch::Approx(0.5f));
}
TEST_CASE("renderPosToMapUV: top-left corner maps to (0, ~0)", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
// wowX = renderPos.y = locLeft = 1000, wowY = renderPos.x = locTop = 1000
glm::vec3 topLeft(1000.0f, 1000.0f, 0.0f);
glm::vec2 uv = renderPosToMapUV(topLeft, bounds, false);
REQUIRE(uv.x == Catch::Approx(0.0f).margin(0.01f));
REQUIRE(uv.y == Catch::Approx(0.0f).margin(0.01f));
}
TEST_CASE("renderPosToMapUV: continent applies vertical offset", "[world_map][coordinate_projection]") {
ZoneBounds bounds;
bounds.locLeft = 1000.0f;
bounds.locRight = -1000.0f;
bounds.locTop = 1000.0f;
bounds.locBottom = -1000.0f;
glm::vec3 center(0.0f, 0.0f, 0.0f);
glm::vec2 zone_uv = renderPosToMapUV(center, bounds, false);
glm::vec2 cont_uv = renderPosToMapUV(center, bounds, true);
// Continent mode applies kVOffset = -0.15
REQUIRE(zone_uv.x == Catch::Approx(cont_uv.x).margin(0.01f));
REQUIRE(cont_uv.y != Catch::Approx(zone_uv.y).margin(0.01f));
}
// ── zoneBelongsToContinent ───────────────────────────────────
TEST_CASE("zoneBelongsToContinent: parent match", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
// Continent at index 0
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "EK"));
// Zone at index 1: parentWorldMapID matches continent's wmaID
zones.push_back(makeZone(2, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 1, "Elwynn"));
REQUIRE(zoneBelongsToContinent(zones, 1, 0) == true);
}
TEST_CASE("zoneBelongsToContinent: no relation", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "EK"));
zones.push_back(makeZone(99, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 50, "Far"));
REQUIRE(zoneBelongsToContinent(zones, 1, 0) == false);
}
TEST_CASE("zoneBelongsToContinent: out of bounds returns false", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f));
REQUIRE(zoneBelongsToContinent(zones, -1, 0) == false);
REQUIRE(zoneBelongsToContinent(zones, 5, 0) == false);
}
// ── isRootContinent / isLeafContinent ────────────────────────
TEST_CASE("isRootContinent detects root with leaf children", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "Root"));
zones.push_back(makeZone(2, 0, 3000.0f, -3000.0f, 3000.0f, -3000.0f, 0, 1, "Leaf"));
REQUIRE(isRootContinent(zones, 0) == true);
REQUIRE(isLeafContinent(zones, 1) == true);
REQUIRE(isRootContinent(zones, 1) == false);
REQUIRE(isLeafContinent(zones, 0) == false);
}
TEST_CASE("isRootContinent: lone continent is not root (no children)", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 0, "Solo"));
REQUIRE(isRootContinent(zones, 0) == false);
}
TEST_CASE("isRootContinent: out of bounds returns false", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
REQUIRE(isRootContinent(zones, 0) == false);
REQUIRE(isRootContinent(zones, -1) == false);
REQUIRE(isLeafContinent(zones, 0) == false);
}
// ── findZoneForPlayer ────────────────────────────────────────
TEST_CASE("findZoneForPlayer: finds smallest containing zone", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
// Continent (ignored: areaID == 0)
zones.push_back(makeZone(1, 0, 10000.0f, -10000.0f, 10000.0f, -10000.0f, 0, 0, "Cont"));
// Large zone
zones.push_back(makeZone(2, 100, 5000.0f, -5000.0f, 5000.0f, -5000.0f, 0, 1, "Large"));
// Small zone fully inside large
zones.push_back(makeZone(3, 200, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0, 1, "Small"));
// Player at center — should find the smaller zone
glm::vec3 playerPos(0.0f, 0.0f, 0.0f);
int found = findZoneForPlayer(zones, playerPos);
REQUIRE(found == 2); // Small zone
}
TEST_CASE("findZoneForPlayer: returns -1 when no zone contains position", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 100, 100.0f, -100.0f, 100.0f, -100.0f, 0, 0, "Tiny"));
glm::vec3 farAway(9999.0f, 9999.0f, 0.0f);
REQUIRE(findZoneForPlayer(zones, farAway) == -1);
}
// ── getContinentProjectionBounds ─────────────────────────────
TEST_CASE("getContinentProjectionBounds: uses continent's own bounds if available", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, 5000.0f, -5000.0f, 3000.0f, -3000.0f, 0, 0, "EK"));
float l, r, t, b;
bool ok = getContinentProjectionBounds(zones, 0, l, r, t, b);
REQUIRE(ok == true);
REQUIRE(l == Catch::Approx(5000.0f));
REQUIRE(r == Catch::Approx(-5000.0f));
REQUIRE(t == Catch::Approx(3000.0f));
REQUIRE(b == Catch::Approx(-3000.0f));
}
TEST_CASE("getContinentProjectionBounds: returns false for out of bounds", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
float l, r, t, b;
REQUIRE(getContinentProjectionBounds(zones, 0, l, r, t, b) == false);
REQUIRE(getContinentProjectionBounds(zones, -1, l, r, t, b) == false);
}
TEST_CASE("getContinentProjectionBounds: rejects non-continent zones", "[world_map][coordinate_projection]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 100, 5000.0f, -5000.0f, 3000.0f, -3000.0f, 0, 0, "Zone"));
float l, r, t, b;
bool ok = getContinentProjectionBounds(zones, 0, l, r, t, b);
REQUIRE(ok == false);
}

View file

@ -0,0 +1,89 @@
// Tests for the extracted world map exploration state module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/exploration_state.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <glm/glm.hpp>
#include <vector>
#include <unordered_map>
#include <unordered_set>
using namespace wowee::rendering::world_map;
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
float locLeft, float locRight,
float locTop, float locBottom,
uint32_t parentWmaID = 0) {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.bounds.locLeft = locLeft;
z.bounds.locRight = locRight;
z.bounds.locTop = locTop;
z.bounds.locBottom = locBottom;
z.parentWorldMapID = parentWmaID;
return z;
}
TEST_CASE("ExplorationState: initially has no server mask", "[world_map][exploration_state]") {
ExplorationState es;
REQUIRE(es.hasServerMask() == false);
REQUIRE(es.exploredZones().empty());
REQUIRE(es.exploredOverlays().empty());
}
TEST_CASE("ExplorationState: setServerMask toggles hasServerMask", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<uint32_t> mask = {0xFF, 0x00, 0x01};
es.setServerMask(mask, true);
REQUIRE(es.hasServerMask() == true);
es.setServerMask({}, false);
REQUIRE(es.hasServerMask() == false);
}
TEST_CASE("ExplorationState: overlaysChanged tracks changes", "[world_map][exploration_state]") {
ExplorationState es;
REQUIRE(es.overlaysChanged() == false);
}
TEST_CASE("ExplorationState: clearLocal resets local data", "[world_map][exploration_state]") {
ExplorationState es;
es.clearLocal();
REQUIRE(es.exploredZones().empty());
}
TEST_CASE("ExplorationState: update with empty zones is safe", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<Zone> zones;
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
glm::vec3 pos(0.0f);
es.update(zones, pos, -1, exploreFlagByAreaId);
REQUIRE(es.exploredZones().empty());
}
TEST_CASE("ExplorationState: update with valid zone and server mask", "[world_map][exploration_state]") {
ExplorationState es;
std::vector<Zone> zones;
auto z = makeZone(1, 100, 1000.0f, -1000.0f, 1000.0f, -1000.0f, 0);
z.exploreBits.push_back(0); // bit 0
OverlayEntry ov;
ov.areaIDs[0] = 100;
z.overlays.push_back(ov);
zones.push_back(z);
// Set server mask with bit 0 set
es.setServerMask({0x01}, true);
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
exploreFlagByAreaId[100] = 0; // AreaID 100 → explore bit 0
glm::vec3 playerPos(0.0f, 0.0f, 0.0f);
es.update(zones, playerPos, 0, exploreFlagByAreaId);
// Zone should be explored since bit 0 is set in the mask
REQUIRE(es.exploredZones().count(0) == 1);
}

View file

@ -0,0 +1,212 @@
// Tests for the map_resolver module — centralized map navigation resolution.
#include <catch_amalgamated.hpp>
#include "rendering/world_map/map_resolver.hpp"
#include "rendering/world_map/world_map_types.hpp"
using namespace wowee::rendering::world_map;
// ── Helper: build minimal zones for testing ──────────────────
static Zone makeZone(uint32_t wmaID, uint32_t areaID,
const std::string& name = "",
uint32_t displayMapID = 0,
uint32_t parentWorldMapID = 0) {
Zone z;
z.wmaID = wmaID;
z.areaID = areaID;
z.areaName = name;
z.displayMapID = displayMapID;
z.parentWorldMapID = parentWorldMapID;
return z;
}
// Build zone list mimicking Azeroth (mapID=0) with root + leaf continents
static std::vector<Zone> buildAzerothZones() {
std::vector<Zone> zones;
// [0] Root continent (areaID=0, has children → isRootContinent)
zones.push_back(makeZone(1, 0, "Azeroth", 0, 0));
// [1] Leaf continent for EK (areaID=0, parentWorldMapID=1 → child of root)
zones.push_back(makeZone(2, 0, "EasternKingdoms", 0, 1));
// [2] Leaf continent for Kalimdor (shouldn't exist on mapID=0, but for testing)
zones.push_back(makeZone(3, 0, "Kalimdor", 1, 1));
// [3] Regular zone
zones.push_back(makeZone(10, 40, "Westfall", 0, 2));
// [4] Regular zone
zones.push_back(makeZone(11, 44, "Redridge", 0, 2));
return zones;
}
// Build zone list with only one continent (no leaf/root distinction)
static std::vector<Zone> buildSimpleZones() {
std::vector<Zone> zones;
// [0] Single continent entry
zones.push_back(makeZone(1, 0, "Kalimdor", 1, 0));
// [1] Zone
zones.push_back(makeZone(10, 331, "Ashenvale", 1, 1));
// [2] Zone
zones.push_back(makeZone(11, 400, "ThousandNeedles", 1, 1));
return zones;
}
// ── mapIdToFolder / folderToMapId / mapDisplayName ───────────
TEST_CASE("mapIdToFolder: known continent IDs",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(0)) == "Azeroth");
REQUIRE(std::string(mapIdToFolder(1)) == "Kalimdor");
REQUIRE(std::string(mapIdToFolder(530)) == "Expansion01");
REQUIRE(std::string(mapIdToFolder(571)) == "Northrend");
}
TEST_CASE("mapIdToFolder: special views",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(UINT32_MAX)) == "World");
REQUIRE(std::string(mapIdToFolder(UINT32_MAX - 1)) == "Cosmic");
}
TEST_CASE("mapIdToFolder: unknown returns empty",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapIdToFolder(9999)) == "");
}
TEST_CASE("folderToMapId: case-insensitive lookup",
"[world_map][map_resolver]") {
REQUIRE(folderToMapId("Azeroth") == 0);
REQUIRE(folderToMapId("azeroth") == 0);
REQUIRE(folderToMapId("KALIMDOR") == 1);
REQUIRE(folderToMapId("Northrend") == 571);
REQUIRE(folderToMapId("world") == static_cast<int>(UINT32_MAX));
REQUIRE(folderToMapId("unknown") == -1);
}
TEST_CASE("mapDisplayName: returns UI labels",
"[world_map][map_resolver]") {
REQUIRE(std::string(mapDisplayName(0)) == "Eastern Kingdoms");
REQUIRE(std::string(mapDisplayName(1)) == "Kalimdor");
REQUIRE(std::string(mapDisplayName(571)) == "Northrend");
REQUIRE(mapDisplayName(9999) == nullptr);
}
// ── findContinentForMapId ────────────────────────────────────
TEST_CASE("findContinentForMapId: prefers leaf continent with matching displayMapID",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 0, -1);
REQUIRE(idx == 1);
}
TEST_CASE("findContinentForMapId: finds leaf by displayMapID=1 for Kalimdor",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 1, -1);
REQUIRE(idx == 2);
}
TEST_CASE("findContinentForMapId: falls back to first non-root continent",
"[world_map][map_resolver]") {
auto zones = buildSimpleZones();
int idx = findContinentForMapId(zones, 999, -1);
REQUIRE(idx == 0);
}
TEST_CASE("findContinentForMapId: skips cosmic zone index",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
int idx = findContinentForMapId(zones, 0, 1);
REQUIRE(idx == 2);
}
TEST_CASE("findContinentForMapId: returns -1 for empty zones",
"[world_map][map_resolver]") {
std::vector<Zone> empty;
int idx = findContinentForMapId(empty, 0, -1);
REQUIRE(idx == -1);
}
// ── resolveWorldRegionClick ──────────────────────────────────
TEST_CASE("resolveWorldRegionClick: same map returns NAVIGATE_CONTINENT",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(0, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::NAVIGATE_CONTINENT);
REQUIRE(result.targetZoneIdx == 1);
}
TEST_CASE("resolveWorldRegionClick: different map returns LOAD_MAP",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(1, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Kalimdor");
}
TEST_CASE("resolveWorldRegionClick: Northrend from Azeroth returns LOAD_MAP",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(571, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Northrend");
}
TEST_CASE("resolveWorldRegionClick: unknown mapId returns NONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveWorldRegionClick(9999, zones, 0, -1);
REQUIRE(result.action == MapResolveAction::NONE);
}
// ── resolveZoneClick ─────────────────────────────────────────
TEST_CASE("resolveZoneClick: normal zone returns ENTER_ZONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveZoneClick(3, zones, 0);
REQUIRE(result.action == MapResolveAction::ENTER_ZONE);
REQUIRE(result.targetZoneIdx == 3);
}
TEST_CASE("resolveZoneClick: zone with different displayMapID returns LOAD_MAP",
"[world_map][map_resolver]") {
std::vector<Zone> zones;
zones.push_back(makeZone(1, 0, "Azeroth", 0, 0));
zones.push_back(makeZone(50, 100, "DarkPortal", 530, 1));
auto result = resolveZoneClick(1, zones, 0);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Expansion01");
}
TEST_CASE("resolveZoneClick: zone with displayMapID matching current returns ENTER_ZONE",
"[world_map][map_resolver]") {
auto zones = buildSimpleZones();
auto result = resolveZoneClick(1, zones, 1);
REQUIRE(result.action == MapResolveAction::ENTER_ZONE);
REQUIRE(result.targetZoneIdx == 1);
}
TEST_CASE("resolveZoneClick: out of range returns NONE",
"[world_map][map_resolver]") {
auto zones = buildAzerothZones();
auto result = resolveZoneClick(-1, zones, 0);
REQUIRE(result.action == MapResolveAction::NONE);
result = resolveZoneClick(99, zones, 0);
REQUIRE(result.action == MapResolveAction::NONE);
}
// ── resolveCosmicClick ───────────────────────────────────────
TEST_CASE("resolveCosmicClick: returns LOAD_MAP for known mapId",
"[world_map][map_resolver]") {
auto result = resolveCosmicClick(530);
REQUIRE(result.action == MapResolveAction::LOAD_MAP);
REQUIRE(result.targetMapName == "Expansion01");
}
TEST_CASE("resolveCosmicClick: returns NONE for unknown mapId",
"[world_map][map_resolver]") {
auto result = resolveCosmicClick(9999);
REQUIRE(result.action == MapResolveAction::NONE);
}

View file

@ -0,0 +1,198 @@
// Tests for the extracted world map view state machine module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/view_state_machine.hpp"
using namespace wowee::rendering::world_map;
TEST_CASE("ViewStateMachine: initial state is CONTINENT", "[world_map][view_state_machine]") {
ViewStateMachine sm;
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
REQUIRE(sm.continentIdx() == -1);
REQUIRE(sm.currentZoneIdx() == -1);
REQUIRE(sm.transition().active == false);
}
TEST_CASE("ViewStateMachine: zoomIn from CONTINENT to ZONE", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(5, 5);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::ZONE);
REQUIRE(result.targetIdx == 5);
REQUIRE(sm.currentLevel() == ViewLevel::ZONE);
REQUIRE(sm.currentZoneIdx() == 5);
}
TEST_CASE("ViewStateMachine: zoomIn from CONTINENT with no zone does nothing", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
}
TEST_CASE("ViewStateMachine: zoomOut from ZONE to CONTINENT", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
sm.setContinentIdx(0);
sm.setCurrentZoneIdx(5);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::CONTINENT);
REQUIRE(result.targetIdx == 0);
REQUIRE(sm.currentLevel() == ViewLevel::CONTINENT);
}
TEST_CASE("ViewStateMachine: zoomOut from ZONE without continent does nothing", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
sm.setContinentIdx(-1);
auto result = sm.zoomOut();
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::ZONE);
}
TEST_CASE("ViewStateMachine: zoomOut from CONTINENT to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomOut from WORLD to COSMIC when enabled", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setCosmicEnabled(true);
auto result = sm.zoomOut();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::COSMIC);
}
TEST_CASE("ViewStateMachine: zoomOut from WORLD stays when cosmic disabled", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setCosmicEnabled(false);
auto result = sm.zoomOut();
REQUIRE(result.changed == false);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomIn from COSMIC goes to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::COSMIC);
sm.setCosmicEnabled(true);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: zoomIn from WORLD to CONTINENT with continent set", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::WORLD);
sm.setContinentIdx(3);
auto result = sm.zoomIn(-1, -1);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::CONTINENT);
REQUIRE(result.targetIdx == 3);
}
TEST_CASE("ViewStateMachine: enterWorldView sets WORLD level", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::ZONE);
auto result = sm.enterWorldView();
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::WORLD);
REQUIRE(sm.currentLevel() == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: enterCosmicView when disabled falls back to WORLD", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setCosmicEnabled(false);
auto result = sm.enterCosmicView();
REQUIRE(result.newLevel == ViewLevel::WORLD);
}
TEST_CASE("ViewStateMachine: enterZone goes to ZONE level", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
auto result = sm.enterZone(7);
REQUIRE(result.changed == true);
REQUIRE(result.newLevel == ViewLevel::ZONE);
REQUIRE(result.targetIdx == 7);
REQUIRE(sm.currentZoneIdx() == 7);
}
// ── Transition animation ─────────────────────────────────────
TEST_CASE("ViewStateMachine: transition starts on zoom", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
REQUIRE(sm.transition().active == true);
REQUIRE(sm.transition().progress == Catch::Approx(0.0f));
}
TEST_CASE("ViewStateMachine: updateTransition advances progress", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
float halfDuration = sm.transition().duration / 2.0f;
bool stillActive = sm.updateTransition(halfDuration);
REQUIRE(stillActive == true);
REQUIRE(sm.transition().progress == Catch::Approx(0.5f).margin(0.01f));
}
TEST_CASE("ViewStateMachine: transition completes after full duration", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
sm.zoomIn(5, 5);
float dur = sm.transition().duration;
sm.updateTransition(dur + 0.1f); // overshoot
REQUIRE(sm.transition().active == false);
REQUIRE(sm.transition().progress == Catch::Approx(1.0f));
}
TEST_CASE("ViewStateMachine: updateTransition when no transition returns false", "[world_map][view_state_machine]") {
ViewStateMachine sm;
REQUIRE(sm.updateTransition(0.1f) == false);
}
TEST_CASE("ViewStateMachine: zoomIn prefers hovered zone over player zone", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(/*hovered=*/3, /*player=*/7);
REQUIRE(result.targetIdx == 3);
}
TEST_CASE("ViewStateMachine: zoomIn falls back to player zone when no hover", "[world_map][view_state_machine]") {
ViewStateMachine sm;
sm.setLevel(ViewLevel::CONTINENT);
sm.setContinentIdx(0);
auto result = sm.zoomIn(/*hovered=*/-1, /*player=*/7);
REQUIRE(result.targetIdx == 7);
}

View file

@ -0,0 +1,86 @@
// Tests for the extracted world map zone metadata module
#include <catch_amalgamated.hpp>
#include "rendering/world_map/zone_metadata.hpp"
#include "rendering/world_map/world_map_types.hpp"
#include <string>
using namespace wowee::rendering::world_map;
TEST_CASE("ZoneMetadata: find returns nullptr for unknown zone", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
REQUIRE(zm.find("NonexistentZoneXYZ") == nullptr);
}
TEST_CASE("ZoneMetadata: find returns valid data for known zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* elwynn = zm.find("Elwynn");
REQUIRE(elwynn != nullptr);
REQUIRE(elwynn->minLevel > 0);
REQUIRE(elwynn->maxLevel >= elwynn->minLevel);
REQUIRE(elwynn->faction == ZoneFaction::Alliance);
}
TEST_CASE("ZoneMetadata: Contested zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* sTV = zm.find("StranglethornVale");
REQUIRE(sTV != nullptr);
REQUIRE(sTV->faction == ZoneFaction::Contested);
}
TEST_CASE("ZoneMetadata: Horde zones", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
const ZoneMeta* durotar = zm.find("Durotar");
REQUIRE(durotar != nullptr);
REQUIRE(durotar->faction == ZoneFaction::Horde);
}
TEST_CASE("ZoneMetadata: formatLabel with no metadata", "[world_map][zone_metadata]") {
std::string label = ZoneMetadata::formatLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("ZoneMetadata: formatLabel with metadata", "[world_map][zone_metadata]") {
ZoneMeta meta;
meta.minLevel = 10;
meta.maxLevel = 20;
meta.faction = ZoneFaction::Alliance;
std::string label = ZoneMetadata::formatLabel("Elwynn", &meta);
// Should contain the zone name
REQUIRE(label.find("Elwynn") != std::string::npos);
}
TEST_CASE("ZoneMetadata: formatHoverLabel with metadata", "[world_map][zone_metadata]") {
ZoneMeta meta;
meta.minLevel = 30;
meta.maxLevel = 40;
meta.faction = ZoneFaction::Contested;
std::string label = ZoneMetadata::formatHoverLabel("StranglethornVale", &meta);
// Should contain both zone name and level range
REQUIRE(label.find("StranglethornVale") != std::string::npos);
REQUIRE(label.find("30") != std::string::npos);
REQUIRE(label.find("40") != std::string::npos);
}
TEST_CASE("ZoneMetadata: formatHoverLabel with no metadata just returns name", "[world_map][zone_metadata]") {
std::string label = ZoneMetadata::formatHoverLabel("UnknownZone", nullptr);
REQUIRE(label == "UnknownZone");
}
TEST_CASE("ZoneMetadata: double initialization is safe", "[world_map][zone_metadata]") {
ZoneMetadata zm;
zm.initialize();
zm.initialize(); // should not crash or change data
const ZoneMeta* elwynn = zm.find("Elwynn");
REQUIRE(elwynn != nullptr);
}