// SPDX-License-Identifier: MPL-2.0 #include "sequa/core/server/protocol.hpp" #include "sequa/core/server/services.hpp" #include #include #include using namespace sequa::core::server; using nlohmann::json; namespace three_d = sequa::three_d; namespace { // Services factices : mémorisent les appels, réponses prévisibles. class FakeServices final : public CoreServices { public: CoreStatus core_status() const override { CoreStatus status; status.core_version = "1.2.3"; status.protocol_version = std::string(protocol_version); status.instance_id = "0123456789abcdef0123456789abcdef"; status.pid = 4242; status.linked = linked; status.controller_attached = controller.has_value(); return status; } AttachResult attach(ConnectionId id, bool force) override { if (controller && *controller != id && !force) { return AttachResult::controller_exists; } controller = id; linked = true; return AttachResult::attached; } DetachResult detach(ConnectionId id) override { if (controller != id) { return DetachResult::not_controller; } linked = false; return DetachResult::detached; } void request_shutdown() override { shutdown = true; } SessionStateView session_start(std::uint64_t new_seed) override { state.seed = new_seed; state.tick = 0; return state; } SessionStateView session_pause() override { state.running = false; return state; } SessionStateView session_resume() override { state.running = true; return state; } SessionStateView session_status() const override { return state; } std::uint64_t session_input(std::uint32_t b, std::int32_t x, std::int32_t y) override { buttons = b; move_x = x; move_y = y; return state.tick; } SessionExportData session_export() const override { SessionExportData data; data.recording = {std::byte{'S'}, std::byte{'Q'}}; data.tick = 2; data.state_hash = 0xABCDEFull; data.hashes = {1, 0x0102030405060708ull}; return data; } std::optional session_import(std::span recording, bool resume) override { if (recording.size() != 2) { return std::nullopt; } SessionImportData data; data.state = state; data.state.running = resume; data.replay_ms = 1.5; return data; } std::optional render_attach(ConnectionId, PushFn) override { return std::string(32, 'a'); } void on_client_connected() override {} void on_client_disconnected(ConnectionId) override {} // --- Rendu (protocole 0.3) --- RenderStartResult render_start(const RenderStartRequest& request) override { last_render_start_request = request; if (render_running) { return RenderStartResult{std::nullopt, "render-running", "Un rendu est déjà en cours."}; } if (render_unavailable) { return RenderStartResult{std::nullopt, "render-unavailable", "Rendu non disponible."}; } render_running = true; RenderStatusView view; view.state = RenderRunState::starting; view.adapter = request.adapter; view.restarts = 0; view.world_version = last_world_version; return RenderStartResult{view, "", ""}; } RenderStatusView render_stop() override { render_running = false; RenderStatusView view; view.state = RenderRunState::stopped; view.restarts = last_restarts; view.world_version = last_world_version; view.last_failure = last_failure.empty() ? std::nullopt : std::optional(last_failure); return view; } RenderStatusView render_status() const override { RenderStatusView view; if (!render_running) { view.state = RenderRunState::stopped; view.restarts = last_restarts; view.world_version = last_world_version; view.last_failure = last_failure.empty() ? std::nullopt : std::optional(last_failure); return view; } view.state = RenderRunState::running; view.adapter = last_adapter; view.pid = 1234; view.restarts = last_restarts; view.engine = three_d::EngineCapabilities{"debug", "0.1", three_d::BufferSet{three_d::FrameBuffer::color}, true, false}; view.width = 1280; view.height = 720; view.measurements = RenderFrames{180, 0x1D5, 6.1, 6.1, 340, 340, 5900, 5900}; view.world_version = last_world_version; view.last_failure = last_failure.empty() ? std::nullopt : std::optional(last_failure); return view; } // État simulé pour les tests. bool render_running = false; bool render_unavailable = false; std::string last_adapter; std::uint32_t last_restarts = 0; std::uint64_t last_world_version = 0x1D5; std::string last_failure; RenderStartRequest last_render_start_request; SessionStateView state{0x2Aull, 60, 10, 0xFFull, true, 3}; std::optional controller; bool linked = false; bool shutdown = false; std::uint32_t buttons = 0; std::int32_t move_x = 0; std::int32_t move_y = 0; }; const std::string hello = encode_hello(protocol_version, "sequa-studio-host"); const std::string render_hello = encode_hello(render_protocol_version, render_client_name); json single(const Reply& reply) { REQUIRE(reply.messages.size() == 1); return json::parse(reply.messages.front()); } json request(ProtocolSession& session, const std::string& type, json payload = json::object(), std::string id = "r1") { const json message{{"v", 1}, {"type", type}, {"id", id}, {"payload", std::move(payload)}}; const Reply reply = session.on_text(message.dump()); CHECK_FALSE(reply.close); return single(reply); } ProtocolSession ready_session(FakeServices& services, ConnectionId id = 1) { ProtocolSession session(services, id); const json welcome = single(session.on_text(hello)); REQUIRE(welcome["type"] == "welcome"); return session; } ProtocolSession render_session(FakeServices& services, ConnectionId id = 1) { ProtocolSession session(services, id, [](std::string) {}); // envoi spontané ignoré const json welcome = single(session.on_text(render_hello)); REQUIRE(welcome["type"] == "welcome"); return session; } void check_rejected(ProtocolSession& session, const std::string& text, const std::string& expected_code) { const Reply reply = session.on_text(text); CHECK(single(reply)["payload"]["code"] == expected_code); CHECK(reply.close); CHECK(reply.close_reason.size() <= 123); CHECK(session.state() == ProtocolSession::State::closed); } } // namespace TEST_CASE("Protocole : compatibilite des versions 0.3") { CHECK(is_compatible_protocol_version("0.3.0")); CHECK(is_compatible_protocol_version("0.3.7-rc.1")); CHECK_FALSE(is_compatible_protocol_version("0.1.0")); CHECK_FALSE(is_compatible_protocol_version("0.2.0")); CHECK_FALSE(is_compatible_protocol_version("1.2.0")); CHECK_FALSE(is_compatible_protocol_version("0.3")); CHECK_FALSE(is_compatible_protocol_version("0.3.0x")); } TEST_CASE("Protocole : welcome porte instanceId, pid et linked") { FakeServices services; ProtocolSession session(services, 1); const json welcome = single(session.on_text(encode_hello(protocol_version, "sequa-studio"))); CHECK(welcome["payload"]["protocolVersion"] == "0.3.0"); CHECK(welcome["payload"]["coreVersion"] == "1.2.3"); CHECK(welcome["payload"]["instanceId"] == "0123456789abcdef0123456789abcdef"); CHECK(welcome["payload"]["pid"] == 4242); CHECK(welcome["payload"]["linked"] == false); } TEST_CASE("Protocole : poignee de main refusee") { FakeServices services; ProtocolSession session(services, 1); SUBCASE("JSON invalide") { check_rejected(session, "{pas du json", "invalid-json"); } SUBCASE("autre message avant hello") { check_rejected(session, R"({"v":1,"type":"core/ping","payload":{}})", "handshake-required"); } SUBCASE("client inconnu") { check_rejected(session, encode_hello(protocol_version, "autre"), "invalid-payload"); } SUBCASE("version 0.1 incompatible") { check_rejected(session, encode_hello("0.1.0", "sequa-studio"), "incompatible-protocol"); } SUBCASE("version 0.2 incompatible") { check_rejected(session, encode_hello("0.2.0", "sequa-studio"), "incompatible-protocol"); } SUBCASE("id non textuel") { check_rejected(session, R"({"v":1,"type":"hello","id":5,"payload":{}})", "invalid-envelope"); } } TEST_CASE("Protocole : id recopie, reponse du meme type") { FakeServices services; auto session = ready_session(services); const json pong = request(session, "core/ping", json::object(), "abc-17"); CHECK(pong["type"] == "core/ping"); CHECK(pong["id"] == "abc-17"); // Sans id : réponse sans id. const json no_id = single(session.on_text(R"({"v":1,"type":"core/ping","payload":{}})")); CHECK_FALSE(no_id.contains("id")); } TEST_CASE("Protocole : erreurs apres la poignee de main sans fermeture") { FakeServices services; auto session = ready_session(services); const json unknown = request(session, "scene/get"); CHECK(unknown["type"] == "error"); CHECK(unknown["id"] == "r1"); CHECK(unknown["payload"]["code"] == "unknown-type"); CHECK(request(session, "session/start", {{"seed", "xyz"}})["payload"]["code"] == "invalid-payload"); CHECK(request(session, "session/start", {{"seed", "12345678901234567"}})["payload"]["code"] == "invalid-payload"); const Reply garbage = session.on_text("pas du json"); CHECK_FALSE(garbage.close); CHECK(single(garbage)["payload"]["code"] == "invalid-json"); CHECK(session.state() == ProtocolSession::State::ready); } TEST_CASE("Protocole : core/status et core/shutdown") { FakeServices services; auto session = ready_session(services); const json status = request(session, "core/status")["payload"]; CHECK(status["instanceId"] == "0123456789abcdef0123456789abcdef"); CHECK(status["controllerAttached"] == false); CHECK(request(session, "core/shutdown")["type"] == "core/shutdown"); CHECK(services.shutdown); } TEST_CASE("Protocole : controleur, attach force et detach") { FakeServices services; auto first = ready_session(services, 1); auto second = ready_session(services, 2); CHECK(request(second, "core/detach")["payload"]["code"] == "not-controller"); CHECK(request(first, "core/attach")["payload"]["linked"] == true); CHECK(request(second, "core/attach")["payload"]["code"] == "controller-exists"); CHECK(request(second, "core/attach", {{"force", "oui"}})["payload"]["code"] == "invalid-payload"); CHECK(request(second, "core/attach", {{"force", true}})["payload"]["linked"] == true); CHECK(services.controller == 2u); CHECK(request(first, "core/detach")["payload"]["code"] == "not-controller"); CHECK(request(second, "core/detach")["payload"]["linked"] == false); } TEST_CASE("Protocole : etat de session en hexadecimal") { FakeServices services; auto session = ready_session(services); const json state = request(session, "session/status")["payload"]; CHECK(state["seed"] == "000000000000002a"); CHECK(state["stateHash"] == "00000000000000ff"); CHECK(state["tickRateHz"] == 60); CHECK(state["tick"] == 10); CHECK(state["inputCount"] == 3); CHECK(request(session, "session/start", {{"seed", "FF"}})["payload"]["seed"] == "00000000000000ff"); CHECK(request(session, "session/pause")["payload"]["running"] == false); CHECK(request(session, "session/resume")["payload"]["running"] == true); } TEST_CASE("Protocole : session/input et ses bornes") { FakeServices services; auto session = ready_session(services); CHECK(request(session, "session/input", {{"buttons", 4294967295u}, {"moveX", -32767}, {"moveY", 32767}})["payload"]["tick"] == 10); CHECK(services.buttons == 4294967295u); CHECK(services.move_x == -32767); CHECK(request(session, "session/input", {{"buttons", 1}, {"moveX", 32768}, {"moveY", 0}})["payload"]["code"] == "invalid-payload"); CHECK(request(session, "session/input", {{"buttons", -1}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload"); CHECK(request(session, "session/input", {{"buttons", 4294967296u}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload"); CHECK(request(session, "session/input", {{"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload"); } TEST_CASE("Protocole : session/export et session/import en base64") { FakeServices services; auto session = ready_session(services); const json exported = request(session, "session/export", {{"includeHashes", true}})["payload"]; CHECK(exported["recording"] == "U1E="); CHECK(exported["tick"] == 2); CHECK(exported["stateHash"] == "0000000000abcdef"); CHECK(exported["hashes"] == "AQAAAAAAAAAIBwYFBAMCAQ=="); // 1 puis 0x0102030405060708, petit-boutiste CHECK_FALSE(request(session, "session/export")["payload"].contains("hashes")); const json imported = request(session, "session/import", {{"recording", "U1E="}, {"resume", false}})["payload"]; CHECK(imported["running"] == false); CHECK(imported["replayMs"] == 1.5); CHECK(request(session, "session/import", {{"recording", "U1E"}, {"resume", true}})["payload"]["code"] == "invalid-recording"); CHECK(request(session, "session/import", {{"recording", "AAAA"}, {"resume", true}})["payload"]["code"] == "invalid-recording"); CHECK(request(session, "session/import", {{"recording", "U1E="}})["payload"]["code"] == "invalid-payload"); } // --- Tests du rendu (protocole 0.3, §9) ---------------------------------------- TEST_CASE("Protocole : render/start valide") { FakeServices services; auto session = ready_session(services); const json result = request(session, "render/start", {{"adapter", "debug"}, {"width", 1280}, {"height", 720}, {"headless", true}, {"fps", 0}}, "rs1"); CHECK(result["type"] == "render/start"); CHECK(result["id"] == "rs1"); const json& payload = result["payload"]; CHECK(payload["state"] == "starting"); CHECK(payload["adapter"] == "debug"); CHECK(payload["restarts"] == 0); CHECK(payload["worldVersion"] == "00000000000001d5"); CHECK(services.last_render_start_request.adapter == "debug"); CHECK(services.last_render_start_request.width == 1280); CHECK(services.last_render_start_request.height == 720); CHECK(services.last_render_start_request.headless == true); CHECK(services.last_render_start_request.fps == 0); } TEST_CASE("Protocole : render/start payloads invalides") { FakeServices services; auto session = ready_session(services); // adapter absent CHECK(request(session, "render/start", {{"width", 1280}}, "id1")["payload"]["code"] == "invalid-payload"); // adapter inconnu CHECK(request(session, "render/start", {{"adapter", "inconnu"}}, "id2")["payload"]["code"] == "invalid-payload"); // width hors bornes (< 64) CHECK(request(session, "render/start", {{"adapter", "debug"}, {"width", 32}}, "id3")["payload"]["code"] == "invalid-payload"); // width hors bornes (> 7680) CHECK(request(session, "render/start", {{"adapter", "debug"}, {"width", 8000}}, "id4")["payload"]["code"] == "invalid-payload"); // height hors bornes CHECK(request(session, "render/start", {{"adapter", "debug"}, {"height", 32}}, "id5")["payload"]["code"] == "invalid-payload"); // headless non booléen CHECK(request(session, "render/start", {{"adapter", "debug"}, {"headless", "oui"}}, "id6")["payload"]["code"] == "invalid-payload"); // fps non entier CHECK(request(session, "render/start", {{"adapter", "debug"}, {"fps", 3.14}}, "id7")["payload"]["code"] == "invalid-payload"); // fps hors bornes CHECK(request(session, "render/start", {{"adapter", "debug"}, {"fps", 250}}, "id8")["payload"]["code"] == "invalid-payload"); } TEST_CASE("Protocole : render/start erreur render-running") { FakeServices services; services.render_running = true; auto session = ready_session(services); const json result = request(session, "render/start", {{"adapter", "debug"}}, "rr1"); CHECK(result["type"] == "error"); CHECK(result["id"] == "rr1"); CHECK(result["payload"]["code"] == "render-running"); CHECK(result["payload"]["message"] == "Un rendu est déjà en cours."); } TEST_CASE("Protocole : render/start erreur render-unavailable") { FakeServices services; services.render_unavailable = true; auto session = ready_session(services); const json result = request(session, "render/start", {{"adapter", "debug"}}, "ru1"); CHECK(result["type"] == "error"); CHECK(result["id"] == "ru1"); CHECK(result["payload"]["code"] == "render-unavailable"); CHECK(result["payload"]["message"] == "Rendu non disponible."); } TEST_CASE("Protocole : render/stop") { FakeServices services; services.render_running = true; services.last_restarts = 2; services.last_world_version = 0xABCD; services.last_failure = "adapter-start : timeout"; auto session = ready_session(services); const json result = request(session, "render/stop", json::object(), "stop1"); CHECK(result["type"] == "render/stop"); CHECK(result["id"] == "stop1"); const json& payload = result["payload"]; CHECK(payload["state"] == "stopped"); CHECK(payload["restarts"] == 2); CHECK(payload["worldVersion"] == "000000000000abcd"); CHECK(payload["lastFailure"] == "adapter-start : timeout"); // adapter, pid, engine, width, height, measurements absents CHECK_FALSE(payload.contains("adapter")); CHECK_FALSE(payload.contains("pid")); CHECK_FALSE(payload.contains("engine")); CHECK_FALSE(payload.contains("width")); CHECK_FALSE(payload.contains("height")); CHECK_FALSE(payload.contains("measurements")); } TEST_CASE("Protocole : render/status stopped par defaut") { FakeServices services; auto session = ready_session(services); const json result = request(session, "render/status", json::object(), "st1"); CHECK(result["type"] == "render/status"); CHECK(result["id"] == "st1"); const json& payload = result["payload"]; CHECK(payload["state"] == "stopped"); CHECK(payload["restarts"] == 0); CHECK(payload["worldVersion"] == "00000000000001d5"); CHECK_FALSE(payload.contains("adapter")); CHECK_FALSE(payload.contains("pid")); CHECK_FALSE(payload.contains("engine")); CHECK_FALSE(payload.contains("width")); CHECK_FALSE(payload.contains("height")); CHECK_FALSE(payload.contains("measurements")); CHECK_FALSE(payload.contains("lastFailure")); } TEST_CASE("Protocole : render/status running avec tous les champs") { FakeServices services; services.render_running = true; services.last_adapter = "debug"; services.last_restarts = 1; services.last_world_version = 0x1D5; auto session = ready_session(services); const json result = request(session, "render/status", json::object(), "st2"); CHECK(result["type"] == "render/status"); CHECK(result["id"] == "st2"); const json& payload = result["payload"]; CHECK(payload["state"] == "running"); CHECK(payload["adapter"] == "debug"); CHECK(payload["pid"] == 1234); CHECK(payload["restarts"] == 1); CHECK(payload["engine"]["name"] == "debug"); CHECK(payload["engine"]["version"] == "0.1"); CHECK(payload["engine"]["buffers"] == json::array({"color"})); CHECK(payload["engine"]["windowed"] == true); CHECK(payload["engine"]["headless"] == false); CHECK(payload["width"] == 1280); CHECK(payload["height"] == 720); CHECK(payload["measurements"]["framesPerSecond"] == 180); CHECK(payload["measurements"]["cpuMsP95"] == 6.1); CHECK(payload["measurements"]["latencyUsP95"] == 340); CHECK(payload["measurements"]["applyUsP95"] == 5900); CHECK(payload["worldVersion"] == "00000000000001d5"); CHECK_FALSE(payload.contains("lastFailure")); } TEST_CASE("Protocole : render/status running avec lastFailure") { FakeServices services; services.render_running = true; services.last_failure = "adapter-start : bibliothèque introuvable"; auto session = ready_session(services); const json result = request(session, "render/status", json::object(), "st3"); const json& payload = result["payload"]; CHECK(payload["lastFailure"] == "adapter-start : bibliothèque introuvable"); } TEST_CASE("Protocole : render/* sur le canal sequa-render -> unknown-type") { FakeServices services; auto session = render_session(services); CHECK(request(session, "render/start", {{"adapter", "debug"}})["payload"]["code"] == "unknown-type"); CHECK(request(session, "render/stop")["payload"]["code"] == "unknown-type"); CHECK(request(session, "render/status")["payload"]["code"] == "unknown-type"); // Les messages du rendu (render/ready, render/frames, etc.) fonctionnent toujours. // Mais core/ping devrait aussi être unknown-type sur le canal de rendu. CHECK(request(session, "core/ping")["payload"]["code"] == "unknown-type"); }