// SPDX-License-Identifier: MPL-2.0 // // Tests du codec JSON provisoire du protocole Core - rendu (spec §5) : hex64, aller-retours, // refus stricts et exemple conforme au texte de la spécification. #include "sequa/3d/wire.hpp" #include #include #include #include #include #include using namespace sequa::three_d; using namespace sequa::three_d::wire; namespace { // Analyse sans exception : le codec ne doit jamais en laisser sortir. [[nodiscard]] nlohmann::json parse_text(const std::string& text) { return nlohmann::json::parse(text, nullptr, false); } // sans operator== défini : comparaison champ par champ. [[nodiscard]] bool capabilities_equal(const EngineCapabilities& left, const EngineCapabilities& right) { return left.engine_name == right.engine_name && left.engine_version == right.engine_version && left.buffers == right.buffers && left.windowed == right.windowed && left.headless == right.headless && left.player_input == right.player_input; } Transform sample_transform() { Transform transform; transform.position = {1, -2.5, 3}; transform.rotation = {0.0f, 0.70710678f, 0.0f, 0.70710678f}; transform.scale = {2, 2, 2}; return transform; } Visual sample_visual(Primitive primitive) { Visual visual; visual.primitive = primitive; visual.size = {0.5, 1.5, 2.5}; visual.color = {0.1f, 0.2f, 0.9f, 0.75f}; visual.semantic_class = 42; return visual; } Transform sample_camera_transform() { Transform transform; transform.position = {5, 6, 7}; return transform; } Camera sample_camera() { Camera camera; camera.transform = sample_camera_transform(); camera.vertical_fov_deg = 60; camera.near_plane = 0.1f; camera.far_plane = 100; return camera; } // Valeurs de l'exemple conforme au texte de la spécification (protocole 0.1 §5). Transform spec_transform() { Transform transform; transform.position = {1, 2, 3}; return transform; } Visual spec_visual() { Visual visual; visual.primitive = Primitive::box; visual.size = {1, 1, 1}; visual.color = {0.5f, 0.6f, 0.7f, 1.0f}; visual.semantic_class = 2; return visual; } Camera spec_camera() { Camera camera; camera.transform.position = {0, 2, 5}; // rotation et échelle par défaut return camera; } } // namespace TEST_CASE("Wire : hex64, 16 caractères minuscules en sortie") { CHECK(to_hex64(0ULL) == "0000000000000000"); CHECK(to_hex64(1ULL) == "0000000000000001"); CHECK(to_hex64(0x0123456789abcdefULL) == "0123456789abcdef"); CHECK(to_hex64(std::numeric_limits::max()) == "ffffffffffffffff"); } TEST_CASE("Wire : from_hex64, de 1 à 16 caractères, majuscules ou minuscules") { CHECK(from_hex64("0") == 0U); CHECK(from_hex64("f") == 15U); CHECK(from_hex64("01a") == 26U); CHECK(from_hex64("01A") == 26U); CHECK(from_hex64("0123456789abcdef") == 0x0123456789abcdefULL); CHECK(from_hex64("0123456789ABCDEF") == 0x0123456789abcdefULL); CHECK(from_hex64("ffffffffffffffff") == std::numeric_limits::max()); // Refus : vide, trop long, caractère hors alphabet. CHECK_FALSE(from_hex64("").has_value()); CHECK_FALSE(from_hex64("0123456789abcdef0").has_value()); CHECK_FALSE(from_hex64("01g").has_value()); CHECK_FALSE(from_hex64("01fG").has_value()); CHECK_FALSE(from_hex64(" 1").has_value()); // Aller-retour sur des valeurs de tous les poids. const std::uint64_t values[] = {1U, 255U, 65536U, 0xdeadbeefcafef00dULL}; for (const std::uint64_t value : values) { CHECK(from_hex64(to_hex64(value)) == value); } } TEST_CASE("Wire : create fait l'aller-retour, pour chaque primitive") { for (const Primitive primitive : {Primitive::box, Primitive::sphere, Primitive::capsule, Primitive::plane}) { const SceneCommand command = CreateObject{0x1a2b3c4d5e6fULL, sample_transform(), sample_visual(primitive)}; const nlohmann::json encoded = encode_command(command); CHECK(encoded["op"] == "create"); CHECK(encoded["id"] == to_hex64(0x1a2b3c4d5e6fULL)); std::string error; const std::optional decoded = decode_command(encoded, &error); REQUIRE(decoded.has_value()); CHECK(error.empty()); CHECK(*decoded == command); CHECK(std::get(*decoded).visual.primitive == primitive); } } TEST_CASE("Wire : destroy, transform, visual et camera font l'aller-retour") { { const SceneCommand command = DestroyObject{0xfeedface0001ULL}; std::optional decoded = decode_command(encode_command(command)); REQUIRE(decoded.has_value()); CHECK(*decoded == command); } { const SceneCommand command = SetTransform{7ULL, sample_transform()}; std::optional decoded = decode_command(encode_command(command)); REQUIRE(decoded.has_value()); CHECK(*decoded == command); } { const SceneCommand command = SetVisual{7ULL, sample_visual(Primitive::capsule)}; std::optional decoded = decode_command(encode_command(command)); REQUIRE(decoded.has_value()); CHECK(*decoded == command); } { const SceneCommand command = SetCamera{sample_camera()}; std::optional decoded = decode_command(encode_command(command)); REQUIRE(decoded.has_value()); CHECK(*decoded == command); } } TEST_CASE("Wire : un SceneDeltaMessage complet fait l'aller-retour") { SceneDeltaMessage message; message.delta.world_version = 0xabcdef9876543210ULL; message.delta.commands = { SceneCommand{CreateObject{1ULL, sample_transform(), sample_visual(Primitive::box)}}, SceneCommand{DestroyObject{2ULL}}, SceneCommand{SetTransform{1ULL, sample_camera_transform()}}, SceneCommand{SetVisual{1ULL, sample_visual(Primitive::sphere)}}, SceneCommand{SetCamera{sample_camera()}}, }; message.tick = 0x123456789ULL; message.full = true; message.sent_at_us = 0x987654321ULL; std::string error; const std::optional decoded = decode_scene_delta(encode_scene_delta(message), &error); REQUIRE(decoded.has_value()); CHECK(error.empty()); CHECK(decoded->delta.world_version == message.delta.world_version); CHECK(decoded->delta.commands == message.delta.commands); CHECK(decoded->tick == message.tick); CHECK(decoded->full == message.full); CHECK(decoded->sent_at_us == message.sent_at_us); const nlohmann::json payload = encode_scene_delta(message); REQUIRE(payload.contains("worldVersion")); CHECK(payload["worldVersion"] == "abcdef9876543210"); CHECK(payload["full"] == true); } TEST_CASE("Wire : les capacités font l'aller-retour") { EngineCapabilities caps; caps.engine_name = "godot"; caps.engine_version = "4.3.stable.official"; caps.buffers = BufferSet::all(); caps.windowed = true; caps.headless = false; const nlohmann::json encoded = encode_capabilities(caps); CHECK(encoded["engineName"] == "godot"); CHECK(encoded["buffers"] == nlohmann::json::array({"color", "depth", "normals", "motion_vectors", "albedo", "object_ids", "semantic_mask"})); std::string error; std::optional decoded = decode_capabilities(encoded, &error); REQUIRE(decoded.has_value()); CHECK(error.empty()); CHECK(capabilities_equal(*decoded, caps)); // Sous-ensemble de tampons : BufferSet vide compris. caps.buffers = BufferSet{FrameBuffer::color, FrameBuffer::semantic_mask}; decoded = decode_capabilities(encode_capabilities(caps)); REQUIRE(decoded.has_value()); CHECK(capabilities_equal(*decoded, caps)); caps.buffers = BufferSet{}; decoded = decode_capabilities(encode_capabilities(caps)); REQUIRE(decoded.has_value()); CHECK(decoded->buffers.empty()); } TEST_CASE("Wire : refus d'un id 0 et d'un id non hexadécimal") { for (const char* id : {"0", "0000000000000000"}) { const std::string text = std::string(R"({"op": "destroy", "id": ")") + id + R"("})"; const nlohmann::json command = parse_text(text); REQUIRE(command.is_object()); std::string error; CHECK_FALSE(decode_command(command, &error).has_value()); CHECK_FALSE(error.empty()); } std::string error; const nlohmann::json bad_format = parse_text(R"({"op": "destroy", "id": "nothex"})"); CHECK_FALSE(decode_command(bad_format, &error).has_value()); CHECK_FALSE(error.empty()); // Trop d'hexadécimal et identifiant numérique : refus (spec : hexadécimal, jamais nombre). const nlohmann::json too_long = parse_text(R"({"op": "destroy", "id": "00000000deadbeef01"})"); CHECK_FALSE(decode_command(too_long).has_value()); const nlohmann::json numeric = parse_text(R"({"op": "destroy", "id": 7})"); CHECK_FALSE(decode_command(numeric).has_value()); } TEST_CASE("Wire : refus des tableaux de mauvaise longueur") { // position à 2 composantes, rotation à 5, size à 3, color à 2. struct Case { const char* text; }; const Case cases[] = { {R"({"op": "transform", "id": "1", "transform": {"position": [1, 2], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}})"}, {R"({"op": "transform", "id": "1", "transform": {"position": [1, 2, 3], "rotation": [0, 0, 0, 1, 1], "scale": [1, 1, 1]}})"}, {R"({"op": "visual", "id": "1", "visual": {"primitive": "box", "size": [1, 1, 1], "color": [0.5, 0.5], "semanticClass": 0}})"}, {R"({"op": "create", "id": "1", "transform": {"position": [1, 2, 3], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "visual": {"primitive": "box", "size": [1], "color": [1, 1, 1, 1], "semanticClass": 0}})"}, }; for (const Case& case_entry : cases) { std::string error; CHECK_FALSE(decode_command(parse_text(case_entry.text), &error).has_value()); CHECK_FALSE(error.empty()); } } TEST_CASE("Wire : refus des nombres non finis") { std::string error; nlohmann::json transform = parse_text( R"({"position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]})"); transform["position"] = nlohmann::json::array( {1.0, std::numeric_limits::quiet_NaN(), 3.0}); CHECK_FALSE(decode_command(nlohmann::json{{"op", "transform"}, {"id", "1"}, {"transform", transform}}, &error) .has_value()); CHECK(error.find("fini") != std::string::npos); transform["position"] = nlohmann::json::array( {1.0, std::numeric_limits::infinity(), 3.0}); CHECK_FALSE(decode_command(nlohmann::json{{"op", "transform"}, {"id", "1"}, {"transform", transform}}).has_value()); // Depuis un texte : nlohmann/json refuse déjà un nombre hors limites (1e999) à la lecture. const nlohmann::json from_text = parse_text( R"({"op": "camera", "camera": {"transform": {"position": [0, 0, 1e999], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "verticalFovDeg": 60, "near": 0.1, "far": 100}})"); CHECK_FALSE(from_text.is_object()); CHECK_FALSE(decode_command(from_text).has_value()); } TEST_CASE("Wire : refus d'une primitive et d'un op inconnus") { std::string error; const nlohmann::json bad_primitive = parse_text( R"({"op": "visual", "id": "1", "visual": {"primitive": "torus", "size": [1, 1, 1], "color": [1, 1, 1, 1], "semanticClass": 0}})"); CHECK_FALSE(decode_command(bad_primitive, &error).has_value()); CHECK(error.find("primitive") != std::string::npos); error.clear(); const nlohmann::json bad_op = parse_text(R"({"op": "rotate", "id": "1"})"); CHECK_FALSE(decode_command(bad_op, &error).has_value()); CHECK(error.find("op") != std::string::npos); } TEST_CASE("Wire : refus d'un champ manquant et d'un mauvais type") { // Champs manquants. for (const char* text : {R"({"id": "1", "visual": {"primitive": "box", "size": [1, 1, 1], "color": [1, 1, 1, 1], "semanticClass": 0}})", R"({"op": "create", "id": "1"})", R"({"op": "create", "id": "1", "transform": {"position": [1, 2, 3], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}})", R"({"op": "camera"})"}) { std::string error; CHECK_FALSE(decode_command(parse_text(text), &error).has_value()); CHECK_FALSE(error.empty()); } // Mauvais types : visual non objet, scale non tableau, semanticClass non entier ou hors 32 bits. for (const char* text : {R"({"op": "visual", "id": "1", "visual": 3})", R"({"op": "transform", "id": "1", "transform": {"position": [1, 2, 3], "rotation": [0, 0, 0, 1], "scale": [1, 1]}})", R"({"op": "visual", "id": "1", "visual": {"primitive": "box", "size": [1, 1, 1], "color": [1, 1, 1, 1], "semanticClass": 1.5}})", R"({"op": "visual", "id": "1", "visual": {"primitive": "box", "size": [1, 1, 1], "color": [1, 1, 1, 1], "semanticClass": -1}})", R"({"op": "visual", "id": "1", "visual": {"primitive": "box", "size": [1, 1, 1], "color": [1, 1, 1, 1], "semanticClass": 4294967296}})"}) { CHECK_FALSE(decode_command(parse_text(text)).has_value()); } // Enveloppe du delta : valeur non hexadécimale, full non booléen, champ manquant. for (const char* text : {R"({"worldVersion": 1, "tick": "1", "full": false, "sentAtUs": "2", "commands": []})", R"({"worldVersion": "1", "tick": "1", "full": "o", "sentAtUs": "2", "commands": []})", R"({"tick": "1", "full": false, "sentAtUs": "2", "commands": []})", R"({"worldVersion": "1", "tick": "1", "full": true, "sentAtUs": "2"})"}) { std::string error; CHECK_FALSE(decode_scene_delta(parse_text(text), &error).has_value()); CHECK_FALSE(error.empty()); } // La commande encodée n'est pas un tableau de commandes, pas plus qu'un objet nu. CHECK_FALSE(decode_commands(nlohmann::json{"not", "an", "array"}).has_value()); CHECK_FALSE(decode_command(nlohmann::json::array()).has_value()); CHECK_FALSE(decode_scene_delta(nlohmann::json::array()).has_value()); // Une entrée malveillante malfomée ne lève pas : parse sans exception puis rejet. CHECK(parse_text("{ no json").is_discarded()); CHECK_FALSE(decode_commands(parse_text("{ no json")).has_value()); } TEST_CASE("Wire : refus d'un tampon inconnu dans les capacités") { std::string error; const nlohmann::json caps = parse_text( R"({"engineName": "godot", "engineVersion": "4.3", "buffers": ["color", "normals"], "windowed": true, "headless": true})"); REQUIRE(decode_capabilities(caps, &error).has_value()); error.clear(); const nlohmann::json bad_caps = parse_text( R"({"engineName": "godot", "engineVersion": "4.3", "buffers": ["color", "godot_power"], "windowed": true, "headless": true})"); CHECK_FALSE(decode_capabilities(bad_caps, &error).has_value()); CHECK(error.find("tampon") != std::string::npos); // Mauvais type et champ manquant. CHECK_FALSE(decode_capabilities(nlohmann::json{{"engineName", 42}}).has_value()); CHECK_FALSE(decode_capabilities(nlohmann::json::array()).has_value()); } TEST_CASE("Wire : playerInput des capacités, facultatif au décodage") { const nlohmann::json without = parse_text( R"({"engineName": "godot", "engineVersion": "4.7", "buffers": [], "windowed": true, "headless": false})"); const auto decoded = decode_capabilities(without); REQUIRE(decoded.has_value()); CHECK_FALSE(decoded->player_input); EngineCapabilities caps; caps.engine_name = "godot"; caps.player_input = true; const auto round_trip = decode_capabilities(encode_capabilities(caps)); REQUIRE(round_trip.has_value()); CHECK(round_trip->player_input); nlohmann::json bad = encode_capabilities(caps); bad["playerInput"] = 1; CHECK_FALSE(decode_capabilities(bad).has_value()); } TEST_CASE("Wire : render/input fait l'aller-retour, bornes comprises") { PlayerInputMessage message; message.input = PlayerInput{0xFFFFFFFFu, axis_max, -axis_max}; message.captured_at_us = 0x123456789ULL; const nlohmann::json encoded = encode_player_input(message); CHECK(encoded["capturedAtUs"] == "0000000123456789"); std::string error; const auto decoded = decode_player_input(encoded, &error); REQUIRE_MESSAGE(decoded.has_value(), error); CHECK(decoded->input == message.input); CHECK(decoded->captured_at_us == message.captured_at_us); } TEST_CASE("Wire : refus d'un render/input hors bornes ou mal typé") { const auto payload = [](nlohmann::json buttons, nlohmann::json x, nlohmann::json y) { return nlohmann::json{{"buttons", std::move(buttons)}, {"moveX", std::move(x)}, {"moveY", std::move(y)}, {"capturedAtUs", "0"}}; }; std::string error; CHECK(decode_player_input(payload(0, 0, 0)).has_value()); CHECK_FALSE(decode_player_input(payload(-1, 0, 0), &error).has_value()); CHECK(error.find("buttons") != std::string::npos); CHECK_FALSE(decode_player_input(payload(0x100000000LL, 0, 0)).has_value()); CHECK_FALSE(decode_player_input(payload(std::numeric_limits::max(), 0, 0)).has_value()); CHECK_FALSE(decode_player_input(payload(0, 32768, 0)).has_value()); CHECK_FALSE(decode_player_input(payload(0, 0, -32768)).has_value()); CHECK_FALSE(decode_player_input(payload(0, 0.5, 0)).has_value()); CHECK_FALSE(decode_player_input(payload("1", 0, 0)).has_value()); nlohmann::json no_time = payload(0, 0, 0); no_time.erase("capturedAtUs"); CHECK_FALSE(decode_player_input(no_time).has_value()); no_time["capturedAtUs"] = "zz"; CHECK_FALSE(decode_player_input(no_time).has_value()); CHECK_FALSE(decode_player_input(nlohmann::json::array()).has_value()); } TEST_CASE("Wire : un delta décodé s'applique à un SceneState sans erreur") { SceneDeltaMessage message; message.delta.world_version = 4; message.delta.commands = { SceneCommand{CreateObject{1ULL, sample_transform(), sample_visual(Primitive::plane)}}, SceneCommand{SetCamera{sample_camera()}}, }; message.tick = 2; message.full = false; message.sent_at_us = 99; std::string error; const std::optional decoded = decode_scene_delta(encode_scene_delta(message), &error); REQUIRE(decoded.has_value()); SceneState state; CHECK_FALSE(state.apply(decoded->delta).has_value()); CHECK(state.world_version() == 4); REQUIRE(state.objects().size() == 1); const auto it = state.objects().find(1); REQUIRE(it != state.objects().end()); CHECK(it->second == ObjectState{sample_transform(), sample_visual(Primitive::plane)}); REQUIRE(state.camera().has_value()); CHECK(*state.camera() == sample_camera()); } TEST_CASE("Wire : décodage d'un exemple conforme au texte de la spec (protocole 0.1 §5)") { const char* text = R"json({ "worldVersion": "0000000000000042", "tick": "0000000000000017", "full": true, "sentAtUs": "00000000000296e6", "commands": [ { "op": "create", "id": "0000000000000001", "transform": { "position": [1.0, 2.0, 3.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0] }, "visual": { "primitive": "box", "size": [1.0, 1.0, 1.0], "color": [0.5, 0.6, 0.7, 1.0], "semanticClass": 2 } }, { "op": "camera", "camera": { "transform": { "position": [0.0, 2.0, 5.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0] }, "verticalFovDeg": 70.0, "near": 0.05, "far": 1000.0 } } ] })json"; const nlohmann::json payload = parse_text(text); REQUIRE(payload.is_object()); std::string error; const std::optional message = decode_scene_delta(payload, &error); REQUIRE(message.has_value()); CHECK(error.empty()); CHECK(message->delta.world_version == 0x42ULL); CHECK(message->tick == 0x17ULL); CHECK(message->full == true); CHECK(message->sent_at_us == 0x296e6ULL); REQUIRE(message->delta.commands.size() == 2); CHECK(message->delta.commands[0] == SceneCommand{CreateObject{1, spec_transform(), spec_visual()}}); CHECK(message->delta.commands[1] == SceneCommand{SetCamera{spec_camera()}}); // Ré-encodage : bytes stables, si ce n'est le formatage du codec. nlohmann::json reencoded = encode_scene_delta(*message); CHECK(reencoded["commands"] == payload["commands"]); }