diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a9fc9b77..17d7cf3a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -414,25 +414,25 @@ target_link_libraries(test_open_formats PRIVATE catch2_main) add_test(NAME open_formats COMMAND test_open_formats) register_test_target(test_open_formats) -# ── test_sql_escape ────────────────────────────────────────── -add_executable(test_sql_escape - test_sql_escape.cpp +# ── test_editor_units (SQL escape, quest validation, …) ───── +add_executable(test_editor_units + test_editor_units.cpp ${CMAKE_SOURCE_DIR}/tools/editor/sql_exporter.cpp ${CMAKE_SOURCE_DIR}/tools/editor/npc_spawner.cpp ${CMAKE_SOURCE_DIR}/tools/editor/quest_editor.cpp ${CMAKE_SOURCE_DIR}/src/core/logger.cpp ) -target_include_directories(test_sql_escape PRIVATE +target_include_directories(test_editor_units PRIVATE ${TEST_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/tools/editor ) -target_include_directories(test_sql_escape SYSTEM PRIVATE +target_include_directories(test_editor_units SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/extern/nlohmann ) -target_link_libraries(test_sql_escape PRIVATE catch2_main) -add_test(NAME sql_escape COMMAND test_sql_escape) -register_test_target(test_sql_escape) +target_link_libraries(test_editor_units PRIVATE catch2_main) +add_test(NAME editor_units COMMAND test_editor_units) +register_test_target(test_editor_units) # ── ASAN / UBSan for test targets ──────────────────────────── if(WOWEE_ENABLE_ASAN AND NOT MSVC) diff --git a/tests/test_editor_units.cpp b/tests/test_editor_units.cpp new file mode 100644 index 00000000..22751ffe --- /dev/null +++ b/tests/test_editor_units.cpp @@ -0,0 +1,107 @@ +// Editor unit tests: +// - SQLExporter::escape — ensures user-provided strings can't produce +// malformed SQL when emitted into INSERT statements. +// - QuestEditor::validateChains — orphan/cycle detection. +#include +#include "sql_exporter.hpp" +#include "quest_editor.hpp" + +using namespace wowee::editor; + +TEST_CASE("SQLExporter::escape doubles single quotes", "[sql]") { + REQUIRE(SQLExporter::escape("King's Land") == "King''s Land"); + REQUIRE(SQLExporter::escape("''''") == "''''''''"); +} + +TEST_CASE("SQLExporter::escape escapes backslashes", "[sql]") { + REQUIRE(SQLExporter::escape("path\\to\\file") == "path\\\\to\\\\file"); +} + +TEST_CASE("SQLExporter::escape passes through ordinary text unchanged", "[sql]") { + REQUIRE(SQLExporter::escape("Hello, world!") == "Hello, world!"); + REQUIRE(SQLExporter::escape("") == ""); + REQUIRE(SQLExporter::escape("Some-Name_123") == "Some-Name_123"); +} + +TEST_CASE("SQLExporter::escape handles control characters", "[sql]") { + // NUL is dropped (some clients don't respect length-prefixed strings) + std::string withNul("a", 1); + withNul += '\0'; + withNul += 'b'; + REQUIRE(SQLExporter::escape(withNul) == "ab"); + + // Newlines/CR/tab become escape sequences so each INSERT stays on one line + REQUIRE(SQLExporter::escape("a\nb") == "a\\nb"); + REQUIRE(SQLExporter::escape("a\rb") == "a\\rb"); + REQUIRE(SQLExporter::escape("a\tb") == "a\\tb"); + + // Ctrl-Z (historical MySQL string terminator on Windows) + std::string withCtrlZ; + withCtrlZ += 'a'; + withCtrlZ += static_cast(26); + withCtrlZ += 'b'; + REQUIRE(SQLExporter::escape(withCtrlZ) == "a\\Zb"); +} + +TEST_CASE("SQLExporter::escape combines escapes correctly", "[sql]") { + REQUIRE(SQLExporter::escape("O'Brien\\path") == "O''Brien\\\\path"); +} + +// ============== Quest validateChains tests ============== + +TEST_CASE("Quest::validateChains flags non-existent next quest", "[quest]") { + QuestEditor qe; + Quest a; + a.title = "First"; + a.questGiverNpcId = 1; + a.nextQuestId = 999; // Doesn't exist + qe.addQuest(a); + + std::vector errors; + REQUIRE_FALSE(qe.validateChains(errors)); + REQUIRE(errors.size() == 1); + REQUIRE(errors[0].find("non-existent quest 999") != std::string::npos); +} + +TEST_CASE("Quest::validateChains flags orphans with no questgiver/turn-in", "[quest]") { + QuestEditor qe; + Quest unreachable; + unreachable.title = "Floating Quest"; + qe.addQuest(unreachable); + + std::vector errors; + REQUIRE_FALSE(qe.validateChains(errors)); + REQUIRE(errors.size() == 1); + REQUIRE(errors[0].find("unreachable") != std::string::npos); +} + +TEST_CASE("Quest::validateChains accepts a quest with only a turn-in NPC", "[quest]") { + QuestEditor qe; + Quest a; + a.title = "Drop quest"; + a.turnInNpcId = 42; // Reachable via turn-in (auto-completed quest) + qe.addQuest(a); + + std::vector errors; + REQUIRE(qe.validateChains(errors)); + REQUIRE(errors.empty()); +} + +TEST_CASE("Quest::validateChains detects circular chain", "[quest]") { + QuestEditor qe; + Quest a; a.title = "Q1"; a.questGiverNpcId = 1; qe.addQuest(a); + Quest b; b.title = "Q2"; b.questGiverNpcId = 1; qe.addQuest(b); + // Set the chain (the addQuest assigns ids 1 and 2 sequentially) + auto* qa = qe.getQuest(0); + auto* qb = qe.getQuest(1); + qa->nextQuestId = qb->id; + qb->nextQuestId = qa->id; // cycle + + std::vector errors; + REQUIRE_FALSE(qe.validateChains(errors)); + bool foundCycle = false; + for (const auto& e : errors) { + if (e.find("Circular") != std::string::npos) foundCycle = true; + } + REQUIRE(foundCycle); +} diff --git a/tests/test_sql_escape.cpp b/tests/test_sql_escape.cpp deleted file mode 100644 index f2f3ac3b..00000000 --- a/tests/test_sql_escape.cpp +++ /dev/null @@ -1,45 +0,0 @@ -// Tests for SQLExporter::escape — ensures user-provided strings can't -// produce malformed SQL when emitted into INSERT statements. -#include -#include "sql_exporter.hpp" - -using namespace wowee::editor; - -TEST_CASE("SQLExporter::escape doubles single quotes", "[sql]") { - REQUIRE(SQLExporter::escape("King's Land") == "King''s Land"); - REQUIRE(SQLExporter::escape("''''") == "''''''''"); -} - -TEST_CASE("SQLExporter::escape escapes backslashes", "[sql]") { - REQUIRE(SQLExporter::escape("path\\to\\file") == "path\\\\to\\\\file"); -} - -TEST_CASE("SQLExporter::escape passes through ordinary text unchanged", "[sql]") { - REQUIRE(SQLExporter::escape("Hello, world!") == "Hello, world!"); - REQUIRE(SQLExporter::escape("") == ""); - REQUIRE(SQLExporter::escape("Some-Name_123") == "Some-Name_123"); -} - -TEST_CASE("SQLExporter::escape handles control characters", "[sql]") { - // NUL is dropped (some clients don't respect length-prefixed strings) - std::string withNul("a", 1); - withNul += '\0'; - withNul += 'b'; - REQUIRE(SQLExporter::escape(withNul) == "ab"); - - // Newlines/CR/tab become escape sequences so each INSERT stays on one line - REQUIRE(SQLExporter::escape("a\nb") == "a\\nb"); - REQUIRE(SQLExporter::escape("a\rb") == "a\\rb"); - REQUIRE(SQLExporter::escape("a\tb") == "a\\tb"); - - // Ctrl-Z (historical MySQL string terminator on Windows) - std::string withCtrlZ; - withCtrlZ += 'a'; - withCtrlZ += static_cast(26); - withCtrlZ += 'b'; - REQUIRE(SQLExporter::escape(withCtrlZ) == "a\\Zb"); -} - -TEST_CASE("SQLExporter::escape combines escapes correctly", "[sql]") { - REQUIRE(SQLExporter::escape("O'Brien\\path") == "O''Brien\\\\path"); -}