Files
SEQUA-Engine/3d/wire/tests/test_wire.cpp
T
Ange-Marie MAURIN - A2M Systèmes 98c778aa28 feat(input): entrees du joueur captees par l'adaptateur (ADR 0011)
L'adaptateur traduit clavier et manette en actions SEQUA (PlayerInput) ;
le Core ne recoit et n'enregistre que ces actions, le rejeu reste exact.

- SEQUA 3D : PlayerInput, poll_input(), capacite player_input
  (interface des adaptateurs en version 2)
- protocole Core - rendu 0.2 : render/input, envoye quand les actions
  changent ; entree remise au neutre si le rendu disparait
- Godot : InputMap du projet (ZQSD, fleches, espace, manette) ou script
  SequaInput.sequa_player_input() pour une correspondance avancee ;
  entree neutre sans focus ; exemple examples/sequa_input.gd
- adaptateur debug : entrees scriptees par numero d'image
- Studio : ligne "Entrees du joueur" du panneau Rendu
2026-11-07 20:48:26 +01:00

534 lines
21 KiB
C++

// 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 <doctest/doctest.h>
#include <cstdint>
#include <limits>
#include <optional>
#include <string>
#include <vector>
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<std::uint64_t>::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<std::uint64_t>::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<SceneCommand> decoded = decode_command(encoded, &error);
REQUIRE(decoded.has_value());
CHECK(error.empty());
CHECK(*decoded == command);
CHECK(std::get<CreateObject>(*decoded).visual.primitive == primitive);
}
}
TEST_CASE("Wire : destroy, transform, visual et camera font l'aller-retour")
{
{
const SceneCommand command = DestroyObject{0xfeedface0001ULL};
std::optional<SceneCommand> decoded = decode_command(encode_command(command));
REQUIRE(decoded.has_value());
CHECK(*decoded == command);
}
{
const SceneCommand command = SetTransform{7ULL, sample_transform()};
std::optional<SceneCommand> decoded = decode_command(encode_command(command));
REQUIRE(decoded.has_value());
CHECK(*decoded == command);
}
{
const SceneCommand command = SetVisual{7ULL, sample_visual(Primitive::capsule)};
std::optional<SceneCommand> decoded = decode_command(encode_command(command));
REQUIRE(decoded.has_value());
CHECK(*decoded == command);
}
{
const SceneCommand command = SetCamera{sample_camera()};
std::optional<SceneCommand> 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<SceneDeltaMessage> 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<EngineCapabilities> 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<double>::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<double>::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<std::uint64_t>::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<SceneDeltaMessage> 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<SceneDeltaMessage> 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"]);
}