Kelsidavis-WoWee/tests/test_world_map.cpp

499 lines
18 KiB
C++
Raw Normal View History

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>
2026-04-12 09:52:51 +03:00
// 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)");
}