#include "TestManager.hpp" #include "MinecraftServer.hpp" #include #include #include #include #include #include #include #include #include std::string ReplaceCharacters(const std::string& in, const std::vector>& replacements = { {'"', "\\\""}, {'\n', "\\n"} }); TestManager::TestManager() { current_offset = {spacing_x, 2, 2 * spacing_z }; current_test_index = 0; bot_index = 0; } TestManager::~TestManager() { } TestManager& TestManager::GetInstance() { static TestManager instance; return instance; } const Botcraft::Position& TestManager::GetCurrentOffset() const { return current_offset; } #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ void TestManager::SetBlock(const std::string& name, const Botcraft::Position& pos, const std::map& blockstates, const std::map& metadata, const bool escape_metadata) const #else void TestManager::SetBlock(const std::string& name, const Botcraft::Position& pos, const int block_variant, const std::map& metadata, const bool escape_metadata) const #endif { MakeSureLoaded(pos); std::stringstream command; command << "setblock" << " " << pos.x << " " << pos.y << " " << pos.z << " " << ((name.size() > 10 && name.substr(0, 10) == "minecraft:") ? "" : "minecraft:") << name; #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ if (!blockstates.empty()) { command << "["; int index = 0; for (const auto& [k, v] : blockstates) { command << k << "="; if (v.find(' ') != std::string::npos || v.find(',') != std::string::npos || v.find(':') != std::string::npos) { // Make sure the whole string is between quotes and all internal quotes are escaped command << "\"" << ReplaceCharacters(v) << "\""; } else { command << v; } command << (index == blockstates.size() - 1 ? "" : ","); index += 1; } command << "]"; } #else command << " " << block_variant << " " << "replace" << " "; #endif if (!metadata.empty()) { command << "{"; int index = 0; for (const auto& [k, v] : metadata) { command << k << ":"; if (escape_metadata && (v.find(' ') != std::string::npos || v.find(',') != std::string::npos || v.find(':') != std::string::npos) ) { // Make sure the whole string is between quotes and all internal quotes are escaped command << "\"" << ReplaceCharacters(v) << "\""; } else { command << v; } command << (index == metadata.size() - 1 ? "" : ","); index += 1; } command << "}"; } #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ command << " replace"; #endif MinecraftServer::GetInstance().SendLine(command.str()); #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ MinecraftServer::GetInstance().WaitLine( ".*: Changed the block at " + std::to_string(pos.x) + ", " + std::to_string(pos.y) + ", " + std::to_string(pos.z) + ".*", 5000); #else MinecraftServer::GetInstance().WaitLine(".*: Block placed.*", 5000); #endif } void TestManager::CreateBook(const Botcraft::Position& pos, const std::vector& pages, const std::string& facing, const std::string& title, const std::string& author, const std::vector& description) { int facing_id = 0; if (facing == "south") { facing_id = 0; } else if (facing == "west") { facing_id = 1; } else if (facing == "north") { facing_id = 2; } else if (facing == "east") { facing_id = 3; } std::stringstream command; command #if PROTOCOL_VERSION < 477 /* < 1.14 */ << "summon" << " " << "minecraft:item_frame" << " " #else << "setblock" << " " #endif << pos.x << " " << pos.y << " " << pos.z << " " #if PROTOCOL_VERSION < 477 /* < 1.14 */ << "{Facing:" << facing_id << "," << "Item:{" #else << "minecraft:lectern" << "[facing=" << facing << ",has_book=true]" << "{" << "Book:{" #endif << "id:\"written_book\"" << "," << "Count:1" << "," << "tag:{" << "pages:["; for (size_t i = 0; i < pages.size(); ++i) { command << "\"{" << "\\\"text\\\"" << ":" << "\\\"" << ReplaceCharacters(pages[i], { {'\n', "\\\\n"}, {'"', "\\\\\\\""} }) << "\\\"" << "}\"" << ((i < pages.size() - 1) ? "," : ""); } command << "]"; if (!title.empty()) { command << "," << "title" << ":" << "\"" << ReplaceCharacters(title) << "\""; } if (!author.empty()) { command << "," << "author" << ":" << "\"" << ReplaceCharacters(author) << "\""; } if (!description.empty()) { command << "," << "display" << ":" << "{Lore:["; for (size_t i = 0; i < description.size(); ++i) { command << "\"" #if PROTOCOL_VERSION < 735 /* < 1.16 */ // Just a list of strings is working before 1.16(?) << ReplaceCharacters(description[i]) #else // After, a JSON text element is required << "{" << "\\\"text\\\"" << ":" << "\\\"" << ReplaceCharacters(description[i], { { '\n', "\\\\n"}, { '"', "\\\\\\\"" } }) << "\\\"" << "}" #endif << "\"" << ((i < description.size() - 1) ? "," : ""); } command << "]}"; } command << "}" // tag << "}" // Item << "}"; // Main MinecraftServer::GetInstance().SendLine(command.str()); #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ && PROTOCOL_VERSION < 477 /* < 1.14 */ MinecraftServer::GetInstance().WaitLine(".*?: Summoned new Item Frame.*", 5000); #elif PROTOCOL_VERSION == 340 /* 1.12.2 */ MinecraftServer::GetInstance().WaitLine(".*?: Object successfully summoned.*", 5000); #else MinecraftServer::GetInstance().WaitLine( ".*: Changed the block at " + std::to_string(pos.x) + ", " + std::to_string(pos.y) + ", " + std::to_string(pos.z) + ".*", 5000); #endif } void TestManager::Teleport(const std::string& name, const Botcraft::Vector3& pos, const float yaw, const float pitch) const { std::stringstream command; command << "teleport" << " " << name << " " << pos.x << " " << pos.y << " " << pos.z << " " << yaw << " " << pitch; MinecraftServer::GetInstance().SendLine(command.str()); MinecraftServer::GetInstance().WaitLine(".*?Teleported " + name + " to.*", 5000); } Botcraft::Position TestManager::GetStructureSize(const std::string& filename) const { std::string no_space_filename = ReplaceCharacters(filename, { {' ', "_"} }); // If there is a # in the file name, only use what's before (useful to have multiple tests sharing the same structure file) size_t split_index = no_space_filename.find('#'); if (split_index != std::string::npos) { no_space_filename = no_space_filename.substr(0, split_index); } const std::filesystem::path filepath = MinecraftServer::GetInstance().GetStructurePath() / (no_space_filename + ".nbt"); if (!std::filesystem::exists(filepath)) { return GetStructureSize("_default"); } std::ifstream file(filepath.string(), std::ios::in | std::ios::binary); file.unsetf(std::ios::skipws); ProtocolCraft::NBT::Value nbt; file >> nbt; file.close(); // TODO: deal with recursive structures (structures containing structure blocks) ? return nbt["size"].as_list_of(); } void TestManager::CreateTPSign(const Botcraft::Position& src, const Botcraft::Vector3& dst, const std::vector& texts, const std::string& facing, const TestSucess success) const { Botcraft::Position offset_target; int block_rotation = 0; if (facing == "north") { block_rotation = 0; offset_target = dst + Botcraft::Position(0, 0, -1); } else if (facing == "south") { block_rotation = 8; offset_target = dst + Botcraft::Position(0, 0, 1); } else if (facing == "east") { block_rotation = 12; offset_target = dst + Botcraft::Position(-1, 0, 0); } else if (facing == "west") { block_rotation = 4; offset_target = dst + Botcraft::Position(1, 0, 0); } std::string text_color; switch (success) { case TestSucess::None: text_color = "black"; break; case TestSucess::Success: text_color = "dark_green"; break; case TestSucess::Failure: text_color = "red"; break; case TestSucess::ExpectedFailure: text_color = "gold"; break; } #if PROTOCOL_VERSION < 763 /* < 1.20 */ std::map lines; #else std::vector lines; lines.reserve(4); #endif for (size_t i = 0; i < std::min(texts.size(), static_cast(4)); ++i) { std::stringstream line; line << "{" << "\"text\":\"" << texts[i] << "\"" << ","; if (i == 0) { line << "\"underlined\"" << ":" << "false" << "," << "\"color\"" << ":" << "\"" << "black" << "\"" << "," << "\"clickEvent\"" << ":" << "{" << "\"action\"" << ":" << "\"run_command\"" << "," << "\"value\"" << ":" << "\"" #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ << "teleport @s" << " " << offset_target.x << " " << offset_target.y << " " << offset_target.z << " " << "facing" << " " << dst.x << " " << dst.y << " " << dst.z #else << "teleport @s" << " " << dst.x << " " << dst.y << " " << dst.z #endif << "\"" << "}"; } else { line << "\"underlined\"" << ":" << "true" << "," << "\"color\"" << ":" << "\"" << text_color << "\""; } line << "}"; #if PROTOCOL_VERSION < 763 /* < 1.20 */ lines.insert({ "Text" + std::to_string(i + 1), line.str() }); #else lines.push_back(line.str()); #endif } // There is a bug in version 1.14 (and prereleases) that requires all 4 lines // to be specified or the server can't read the text. See: https://bugs.mojang.com/browse/MC-144316 #if PROTOCOL_VERSION > 459 /* > 1.13.2 */ && PROTOCOL_VERSION < 478 /* < 1.14.1 */ for (size_t i = texts.size(); i < 4ULL; ++i) { lines.insert({ "Text" + std::to_string(i + 1), "{\"text\":\"\"}" }); } #endif #if PROTOCOL_VERSION > 762 /* > 1.19.4 */ // In 1.20 texts are sent as a list with exactly 4 elements std::stringstream text_content; text_content << "{\"messages\":["; for (size_t i = 0; i < 4ULL; ++i) { if (i < lines.size()) { text_content << "\"" << ReplaceCharacters(lines[i]) << "\""; } else { text_content << "\"" << ReplaceCharacters("{\"text\":\"\"}") << "\""; } if (i != 3ULL) { text_content << ","; } } text_content << "]}"; const std::string text_content_str = text_content.str(); #endif SetBlock( #if PROTOCOL_VERSION < 393 /* < 1.13 */ "standing_sign", #elif PROTOCOL_VERSION < 477 /* < 1.14 */ "sign", #else "oak_sign", #endif src, #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ { { "rotation", std::to_string(block_rotation) } }, #else block_rotation, #endif #if PROTOCOL_VERSION < 763 /* < 1.20 */ lines #else { { "is_waxed", "true" }, { "front_text", text_content_str }, { "back_text", text_content_str }, }, false #endif ); } void TestManager::LoadStructure(const std::string& filename, const Botcraft::Position& pos, const Botcraft::Position& load_offset) const { std::string no_space_filename = ReplaceCharacters(filename, { {' ', "_"} }); // If there is a # in the file name, only use what's before (useful to have multiple tests sharing the same structure file) size_t split_index = no_space_filename.find('#'); if (split_index != std::string::npos) { no_space_filename = no_space_filename.substr(0, split_index); } const std::string& loaded = std::filesystem::exists(MinecraftServer::GetInstance().GetStructurePath() / (no_space_filename + ".nbt")) ? no_space_filename : "_default"; SetBlock( "structure_block", pos, #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ {}, #else 0, #endif { {"mode", "LOAD"}, {"name", loaded}, {"posX", std::to_string(load_offset.x)}, {"posY", std::to_string(load_offset.y)}, {"posZ", std::to_string(load_offset.z)}, {"showboundingbox", "1"} } ); SetBlock("redstone_block", pos + Botcraft::Position(0, 1, 0)); } void TestManager::MakeSureLoaded(const Botcraft::Position& pos) const { std::shared_ptr world = chunk_loader->GetWorld(); if (chunk_loader->GetWorld()->IsLoaded(pos)) { return; } Teleport(chunk_loader_name, pos); // Wait for bot to load center and corner view_distance blocks const int chunk_x = static_cast(std::floor(pos.x / static_cast(Botcraft::CHUNK_WIDTH))); const int chunk_z = static_cast(std::floor(pos.z / static_cast(Botcraft::CHUNK_WIDTH))); // -1 because sometimes corner chunks are not sent const int view_distance = MinecraftServer::options.view_distance - 1; std::vector> wait_loaded = { {chunk_x * Botcraft::CHUNK_WIDTH, chunk_z * Botcraft::CHUNK_WIDTH}, {(chunk_x + view_distance) * Botcraft::CHUNK_WIDTH - 1, (chunk_z + view_distance) * Botcraft::CHUNK_WIDTH - 1}, {(chunk_x - view_distance) * Botcraft::CHUNK_WIDTH, (chunk_z + view_distance) * Botcraft::CHUNK_WIDTH - 1}, {(chunk_x + view_distance) * Botcraft::CHUNK_WIDTH - 1, (chunk_z - view_distance) * Botcraft::CHUNK_WIDTH}, {(chunk_x - view_distance) * Botcraft::CHUNK_WIDTH, (chunk_z - view_distance) * Botcraft::CHUNK_WIDTH} }; if (!Botcraft::Utilities::WaitForCondition([&]() { for (size_t i = 0; i < wait_loaded.size(); ++i) { if (!world->IsLoaded(Botcraft::Position(wait_loaded[i].first, pos.y, wait_loaded[i].second))) { return false; } } return true; }, 15000)) { LOG_ERROR("Timeout waiting " << chunk_loader_name << " to load surroundings from " << chunk_loader->GetLocalPlayer()->GetPosition()); for (size_t i = 0; i < wait_loaded.size(); ++i) { const Botcraft::Position pos(wait_loaded[i].first, pos.y, wait_loaded[i].second); LOG_ERROR(pos << " is" << (world->IsLoaded(pos) ? "" : " not") << " loaded"); } std::stringstream loaded; loaded << "\n"; for (const auto& c : *world->GetChunks()) { loaded << c.first.first << "\t" << c.first.second << ";"; } LOG_ERROR("Loaded chunks: " << loaded.str()); throw std::runtime_error("Timeout waiting " + chunk_loader_name + " to load surroundings"); } } void TestManager::SetGameMode(const std::string& name, const Botcraft::GameType gamemode) const { std::string gamemode_string; switch (gamemode) { case Botcraft::GameType::Survival: gamemode_string = "survival"; break; case Botcraft::GameType::Creative: gamemode_string = "creative"; break; case Botcraft::GameType::Adventure: gamemode_string = "adventure"; break; case Botcraft::GameType::Spectator: gamemode_string = "spectator"; break; default: return; } std::stringstream command; command << "gamemode" << " " << gamemode_string << " " << name; MinecraftServer::GetInstance().SendLine(command.str()); MinecraftServer::GetInstance().WaitLine(".*? Set " + name + "'s game mode to.*", 5000); } void TestManager::testRunStarting(Catch::TestRunInfo const& test_run_info) { // Make sure the server is running and ready before the first test run MinecraftServer::GetInstance().Initialize(); // Retrieve header size header_size = GetStructureSize("_header_running"); chunk_loader = GetBot(chunk_loader_name, Botcraft::GameType::Spectator); } void TestManager::testCaseStarting(Catch::TestCaseInfo const& test_info) { // Retrieve test structure size current_test_size = GetStructureSize(test_info.name); LoadStructure("_header_running", Botcraft::Position(current_offset.x, 0, spacing_z - header_size.z)); current_offset.z = 2 * spacing_z; } void TestManager::testCasePartialStarting(Catch::TestCaseInfo const& test_info, uint64_t part_number) { // Load header current_header_position = current_offset - Botcraft::Position(0, 2, 0); LoadStructure("_header_running", current_header_position); current_offset.z += header_size.z; // Load test structure LoadStructure(test_info.name, current_offset + Botcraft::Position(0, -1, 0), Botcraft::Position(0, 1, 0)); current_test_case_failures.clear(); section_stack = { std::filesystem::path(test_info.lineInfo.file).stem().string() }; } void TestManager::sectionStarting(Catch::SectionInfo const& section_info) { section_stack.push_back(section_info.name); } void TestManager::assertionEnded(Catch::AssertionStats const& assertion_stats) { if (!assertion_stats.assertionResult.succeeded()) { const std::filesystem::path file_path = std::filesystem::relative(assertion_stats.assertionResult.getSourceInfo().file, BASE_SOURCE_DIR); current_test_case_failures.push_back( ReplaceCharacters(file_path.string(), {{'\\', "/"}}) + ":" + std::to_string(assertion_stats.assertionResult.getSourceInfo().line) + "\n\n" + assertion_stats.assertionResult.getExpressionInMacro() + "\n\n" "with expansion:" + "\n" + assertion_stats.assertionResult.getExpandedExpression() ); } } void TestManager::testCasePartialEnded(Catch::TestCaseStats const& test_case_stats, uint64_t part_number) { const bool passed = test_case_stats.totals.assertions.allPassed(); // Replace header with proper test result LoadStructure(passed ? "_header_success" : (test_case_stats.testInfo->okToFail() ? "_header_expected_fail" : "_header_fail"), current_header_position); // Create TP sign for the partial that just ended CreateTPSign( Botcraft::Position(-2 * (current_test_index + 1), 2, -2 * part_number - 5), Botcraft::Vector3(current_offset.x, 2, current_offset.z - 1), section_stack, "north", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure) ); // Create back to spawn sign for the section that just ended CreateTPSign( Botcraft::Position(current_offset.x, 2, current_offset.z - 1), Botcraft::Vector3(-2 * (current_test_index + 1), 2, -2 * static_cast(part_number) - 5), section_stack, "south", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure) ); if (!passed) { CreateBook( Botcraft::Position(current_offset.x + 1, 2, current_offset.z - header_size.z), current_test_case_failures, "north", test_case_stats.testInfo->name + "#" + std::to_string(part_number), "Botcraft Test Framework", section_stack ); } current_offset.z += current_test_size.z + spacing_z; // Kill all items that could exist on the floor MinecraftServer::GetInstance().SendLine("kill @e[type=item]"); #if PROTOCOL_VERSION > 340 /* > 1.12.2 */ // In 1.12.2 server sends one line per entity killed so we can't wait without knowing // how many there were. Just assume the command worked MinecraftServer::GetInstance().WaitLine(".*?: (?:Killed|No entity was found).*", 5000); #endif } void TestManager::testCaseEnded(Catch::TestCaseStats const& test_case_stats) { const bool passed = test_case_stats.totals.assertions.allPassed(); LoadStructure(passed ? "_header_success" : (test_case_stats.testInfo->okToFail() ? "_header_expected_fail" : "_header_fail"), Botcraft::Position(current_offset.x, 0, spacing_z - header_size.z)); // Create Sign to TP to current test CreateTPSign( Botcraft::Position(-2 * (current_test_index + 1), 2, -2), Botcraft::Vector3(current_offset.x, 2, spacing_z - 1), { std::filesystem::path(test_case_stats.testInfo->lineInfo.file).stem().string(), test_case_stats.testInfo->name }, "north", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure) ); // Create sign to TP to TP back to spawn CreateTPSign( Botcraft::Position(current_offset.x, 2, spacing_z - 1), Botcraft::Vector3(-2 * (current_test_index + 1), 2, -2), { std::filesystem::path(test_case_stats.testInfo->lineInfo.file).stem().string(), test_case_stats.testInfo->name }, "south", passed ? TestSucess::Success : (test_case_stats.testInfo->okToFail() ? TestSucess::ExpectedFailure : TestSucess::Failure) ); current_test_index += 1; current_offset.x += std::max(current_test_size.x, header_size.x) + spacing_x; } void TestManager::testRunEnded(Catch::TestRunStats const& test_run_info) { if (chunk_loader) { chunk_loader->Disconnect(); } } void TestManagerListener::testRunStarting(Catch::TestRunInfo const& test_run_info) { TestManager::GetInstance().testRunStarting(test_run_info); } void TestManagerListener::testCaseStarting(Catch::TestCaseInfo const& test_info) { TestManager::GetInstance().testCaseStarting(test_info); } void TestManagerListener::testCasePartialStarting(Catch::TestCaseInfo const& test_info, uint64_t part_number) { TestManager::GetInstance().testCasePartialStarting(test_info, part_number); } void TestManagerListener::sectionStarting(Catch::SectionInfo const& section_info) { TestManager::GetInstance().sectionStarting(section_info); } void TestManagerListener::assertionEnded(Catch::AssertionStats const& assertion_stats) { TestManager::GetInstance().assertionEnded(assertion_stats); } void TestManagerListener::testCasePartialEnded(Catch::TestCaseStats const& test_case_stats, uint64_t part_number) { TestManager::GetInstance().testCasePartialEnded(test_case_stats, part_number); } void TestManagerListener::testCaseEnded(Catch::TestCaseStats const& test_case_stats) { TestManager::GetInstance().testCaseEnded(test_case_stats); } void TestManagerListener::testRunEnded(Catch::TestRunStats const& test_run_info) { TestManager::GetInstance().testRunEnded(test_run_info); } CATCH_REGISTER_LISTENER(TestManagerListener) std::string ReplaceCharacters(const std::string& in, const std::vector>& replacements) { std::string output; output.reserve(in.size()); for (size_t i = 0; i < in.size(); ++i) { bool found = false; for (size_t j = 0; j < replacements.size(); ++j) { if (replacements[j].first == in[i]) { output += replacements[j].second; found = true; break; } } if (!found) { output += in[i]; } } return output; }