Files
aee-arcade/tests/test_entities_smoke.cpp
T

391 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Smoke tests del sistema d'entitats. Stand-alone, sense frameworks externs.
//
// Tests coberts:
// T1 factory — tots els tipus es registren i es creen.
// T2 loader — llegeix `data/maps/test_entities.yaml` i retorna els spawns.
// T3 event bus — subscribe + publish + payload (entity*, int).
// T4 sarcòfag — perímetre computat correctament; notifyTileEntered
// passa a OPENING quan es visiten tots els tiles.
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
// s'obri sola; contactar la porta OPEN emet `level_complete`.
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16.
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_entities_smoke
#include <cassert>
#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
#include "game/entities/animation_component.hpp"
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/floor_trap.hpp"
#include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/rolling_rock.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/torch.hpp"
#include "game/entities/concrete/wall_trap.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
void testFactory() {
std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance();
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
auto ent = factory.create(type, tmpl);
REQUIRE(ent != nullptr);
if (ent != nullptr) { REQUIRE(ent->getName() == type); }
}
}
void testLoader() {
std::cout << "[T2] loader\n";
auto spawns = EntityLoader::readSpawns("data/maps/test_entities.yaml");
// Al test_entities.yaml hem declarat 5 spawns.
REQUIRE(spawns.size() == 5);
// Primer spawn és torch.
if (!spawns.empty()) {
REQUIRE(spawns[0]["type"].get_value<std::string>() == "torch");
}
}
void testEventBus() {
std::cout << "[T3] event bus\n";
EventBus bus;
int int_received = -1;
int entity_ptr_flag = 0;
bus.subscribe("a_int", [&](const Event& ev) {
const int* v = std::get_if<int>(&ev.payload);
if (v != nullptr) { int_received = *v; }
});
bus.subscribe("a_ent", [&](const Event& ev) {
if (std::get_if<Entity*>(&ev.payload) != nullptr) { entity_ptr_flag = 1; }
});
bus.publish("a_int", 42);
REQUIRE(int_received == 42);
Entity fake;
bus.publish("a_ent", &fake);
REQUIRE(entity_ptr_flag == 1);
// Publish d'un nom sense subscriptors no ha de crashejar.
bus.publish("desconegut");
// Unsubscribe elimina els handlers.
auto id = bus.subscribe("a_int", [&](const Event&) { int_received = 999; });
bus.unsubscribe(id);
bus.publish("a_int", 7);
REQUIRE(int_received == 7);
}
void testSarcophagusPerimeter() {
std::cout << "[T4] sarcofag perimetre\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense
// BlockedPredicate):
// Nord: (1,0), (2,0), (3,0)
// Sud: (1,2), (2,2), (3,2)
// Oest: (0,1)
// Est: (4,1)
// Total: 8 tiles.
sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTileEntered(5, 5);
REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1},
};
for (const auto& [c, r] : PERIM) { sarc->notifyTileEntered(c, r); }
REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa
// la transició a OPEN.
sarc->update(0.0F);
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
}
void testEntityManagerEndToEnd() {
std::cout << "[T5] manager end-to-end\n";
EntityManager manager;
// 2 sarcòfags, 1 porta.
auto sarc1 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc1->place(0, 0, {});
auto sarc2 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc2->place(4, 4, {});
Sarcophagus* s1 = manager.addSarcophagus(std::move(sarc1));
Sarcophagus* s2 = manager.addSarcophagus(std::move(sarc2));
REQUIRE(manager.sarcophagusCount() == 2);
auto door = std::make_unique<Door>(EntityLoader::loadTemplate("door"));
Door* door_ptr = door.get();
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Subscriptor local per capturar `level_complete`.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth();
const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) {
s->notifyTileEntered(COL0 + dc, ROW0 - 1);
s->notifyTileEntered(COL0 + dc, ROW0 + H);
}
for (int dr = 0; dr < H; ++dr) {
s->notifyTileEntered(COL0 - 1, ROW0 + dr);
s->notifyTileEntered(COL0 + W, ROW0 + dr);
}
};
forceOpen(s1);
forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
// l'handler del manager compta i, en arribar al total, crida
// door->open() — que en aquest mateix tick (la iteració sobre
// entities continua) farà transitar la porta OPENING → OPEN perquè
// l'animació és d'1 frame loop=false.
manager.update(0.0F);
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
// Contacte amb la porta oberta → level_complete.
door_ptr->onPlayerContact(nullptr);
REQUIRE(level_complete_fired);
// Un segon contacte NO torna a disparar (idempotent).
level_complete_fired = false;
door_ptr->onPlayerContact(nullptr);
REQUIRE(!level_complete_fired);
}
void testAnimationComponent() {
std::cout << "[T6] animation component\n";
AnimationComponent anim({10, 20, 30}, 10.0F, true);
REQUIRE(anim.frameCount() == 3);
REQUIRE(anim.currentFrame() == 10);
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1
REQUIRE(anim.currentFrame() == 20);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 30);
// Loop: un tick més i torna al principi.
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 10);
// Variant no-loop: es para al darrer i queda completed.
AnimationComponent once({1, 2}, 10.0F, false);
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
REQUIRE(once.currentFrame() == 2);
REQUIRE(once.completed());
// Variant d'1 frame no-loop: completed des del principi.
AnimationComponent single({5}, 1.0F, false);
REQUIRE(single.completed());
}
void testAllTemplatesLoad() {
std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
try {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
REQUIRE(tmpl.contains("name"));
REQUIRE(tmpl["name"].get_value<std::string>() == type);
} catch (const std::exception& e) {
std::cerr << " excepció carregant '" << type << "': " << e.what() << '\n';
++g_failures;
}
}
}
void testDecorationFlags() {
std::cout << "[T8] decoration flags\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
Torch torch(tmpl);
REQUIRE(!torch.collides());
REQUIRE(!torch.interactable());
}
void testLoadFromLevel() {
std::cout << "[T9] pyramid level load (globals + spawns + tile math + bus)\n";
// 1) Map::loadFrom carrega les tres globals noves (tile_size, origen).
Map::loadFrom("data/maps/pyramid.yaml");
REQUIRE(Map::tile_size == 16);
REQUIRE(Map::tilemap_origin_x == -8);
REQUIRE(Map::tilemap_origin_y == 0);
// 2) El pyramid.yaml conté spawns dels tipus esperats. Comptem al
// menys un de cada tipus que el nivell ha de tenir sí o sí (no
// fixem quants torches exactes perquè és decoratiu i pot
// variar). Així afegir decoració al YAML no trenca el smoke.
const auto SPAWNS = EntityLoader::readSpawns("data/maps/pyramid.yaml");
REQUIRE(!SPAWNS.empty());
int torches = 0;
int sarcs = 0;
int doors = 0;
int mummies = 0;
for (const auto& s : SPAWNS) {
const auto T = s["type"].get_value<std::string>();
if (T == "torch") { ++torches; }
else if (T == "sarcophagus") { ++sarcs; }
else if (T == "door") { ++doors; }
else if (T == "mummy") { ++mummies; }
}
REQUIRE(torches >= 1);
REQUIRE(sarcs >= 1);
REQUIRE(doors >= 1);
REQUIRE(mummies >= 1);
// 3) La conversió sarc-grid (0,0) → tile anchor (3,4) (i.e. la
// fórmula que usa loadFromLevel). Sarc rect = (40, 64, 48, 16).
const auto RECT = Map::getSarcRect(0, 0);
REQUIRE(static_cast<int>(RECT.x) == 40);
REQUIRE(static_cast<int>(RECT.y) == 64);
const int ANCHOR_COL = (static_cast<int>(RECT.x) - Map::tilemap_origin_x) / Map::tile_size;
const int ANCHOR_ROW = (static_cast<int>(RECT.y) - Map::tilemap_origin_y) / Map::tile_size;
REQUIRE(ANCHOR_COL == 3);
REQUIRE(ANCHOR_ROW == 4);
// 4) Fan-out del bus: un sarcòfag ancorat a (3,4) mida 3×1 rep via
// on_tile_entered una visita sobre el seu perímetre → state canvia.
EntityManager manager;
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {});
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
REQUIRE(sarc_raw->getPerimeterSize() == 8);
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4).
const std::pair<int, int> PERIM[] = {
{3, 3}, {4, 3}, {5, 3},
{3, 5}, {4, 5}, {5, 5},
{2, 4}, {6, 4},
};
for (const auto& [c, r] : PERIM) {
manager.getBus().publish("on_tile_entered", TilePos{.col = c, .row = r});
}
REQUIRE(sarc_raw->getState() == Sarcophagus::State::OPENING);
}
void testSarcophagusYaml() {
std::cout << "[T11] sarcofag YAML + falsa perspectiva\n";
// El template ha de declarar tile_size [3,1]: el footprint lògic
// són els 48×16 inferiors del sprite, encara que el sprite sencer
// fa 48×32 (16 px de "alçada" per damunt, sense col·lisió).
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
REQUIRE(tmpl.contains("tile_size"));
REQUIRE(tmpl["tile_size"][0].get_value<int>() == 3);
REQUIRE(tmpl["tile_size"][1].get_value<int>() == 1);
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8
// tiles de perímetre (no es converteix en 10 per culpa del [3,2]
// antic). Això blinda la geometria contra regressions al YAML.
auto sarc = std::make_unique<Sarcophagus>(tmpl);
sarc->place(3, 4, {});
REQUIRE(sarc->getPerimeterSize() == 8);
}
void testTorchAnimation() {
std::cout << "[T10] torch animation wired from yaml\n";
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
// consultava `currentFrame()`, així que una antorxa animada no es
// veia viva. Ara el YAML ha de portar 4 frames i l'Entity::update
// ha d'avançar el frame via el component d'animació.
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
Torch torch(tmpl);
const auto& ANIM = torch.getAnimation();
REQUIRE(ANIM.has_value());
REQUIRE(ANIM->frameCount() == 4);
REQUIRE(ANIM->currentFrame() == 0);
// Amb fps=8, el pas entre frames és 0.125s. Un tick d'aquest valor
// ha de fer saltar current_ a 1 (i, com que `frames: [0..3]`, el
// valor retornat pel currentFrame coincideix amb l'índex).
torch.update(0.125F);
REQUIRE(torch.getAnimation()->currentFrame() == 1);
torch.update(0.125F);
REQUIRE(torch.getAnimation()->currentFrame() == 2);
}
} // namespace
auto main() -> int {
std::cout << "== smoke tests: entity system ==\n";
testFactory();
testLoader();
testEventBus();
testSarcophagusPerimeter();
testEntityManagerEndToEnd();
testAnimationComponent();
testAllTemplatesLoad();
testDecorationFlags();
testLoadFromLevel();
testSarcophagusYaml();
testTorchAnimation();
if (g_failures == 0) {
std::cout << "ALL OK\n";
return 0;
}
std::cerr << g_failures << " smoke check(s) failed\n";
return 1;
}