Files
SEQUA-Engine/core/tests/server/test_server.cpp
T
Ange-Marie MAURIN - A2M Systèmes eb4db9e01f feat(render): processus de rendu lancé par le Core, scène simulée affichée dans Godot
ADR 0009 (Proposée) et spécification du protocole Core - rendu 0.1. Le Core projette le monde simulé (micro-mode 2D, SIMULÉ) en deltas SEQUA 3D (sequa::core_render) et les pousse après chaque tick qui change la scène (RenderHub, observateur de LiveSession) ; il lance et supervise sequa-render (RenderSupervisor : lié au Core, relance après plantage). sequa-render charge une bibliothèque d'adaptateur par un point d'entrée C versionné (3d/adapter_library.hpp), applique les deltas et rend les images ; codec JSON provisoire partagé dans 3d/wire. Adaptateur Godot en DLL et nouvel adaptateur de débogage (engines/debug/adapter : vue de côté en images PPM ou vidéo via ffmpeg externe). Protocole Studio - Core 0.3 : render/start, render/stop, render/status (Core et Studio host). Mesuré avec Godot : transport tick -> delta reçu p95 ≈ 0,3 ms (critère 4 ms).
2026-11-07 14:42:09 +01:00

321 lines
11 KiB
C++

// SPDX-License-Identifier: MPL-2.0
//
// Tests d'intégration : un vrai client WebSocket (IXWebSocket) contre le CoreServer et un
// vrai CoreRuntime, en boucle locale (spec 0.2 §3 à §5).
#include "sequa/core/server/protocol.hpp"
#include "sequa/core/server/runtime.hpp"
#include "sequa/core/server/server.hpp"
#include <doctest/doctest.h>
#include <ixwebsocket/IXNetSystem.h>
#include <ixwebsocket/IXWebSocket.h>
#include <nlohmann/json.hpp>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using namespace sequa::core::server;
using nlohmann::json;
using namespace std::chrono_literals;
namespace {
constexpr auto timeout = std::chrono::seconds(5);
struct Fixture {
explicit Fixture(ServerOptions options = {}, CoreRuntimeOptions runtime_options = make_runtime_options())
: runtime(std::make_unique<CoreRuntime>(runtime_options))
{
// IXWebSocket ne rapporte pas le port choisi pour le port 0 : on essaie une plage.
for (int port = 19100; port < 19200; ++port) {
options.port = port;
auto candidate = std::make_unique<CoreServer>(options, *runtime);
if (candidate->start().empty()) {
server = std::move(candidate);
return;
}
}
FAIL("aucun port libre entre 19100 et 19199");
}
static CoreRuntimeOptions make_runtime_options()
{
CoreRuntimeOptions options;
options.core_version = "9.9.9-test";
options.real_time = false;
options.lease_grace = 100ms;
options.attach_deadline = 5s;
return options;
}
[[nodiscard]] std::string url(const std::string& query = {}) const
{
return "ws://127.0.0.1:" + std::to_string(server->options().port) + "/" + query;
}
std::unique_ptr<CoreRuntime> runtime; // survit au serveur (déclaré avant)
std::unique_ptr<CoreServer> server;
};
class TestClient {
public:
explicit TestClient(const std::string& url, const ix::WebSocketHttpHeaders& headers = {})
{
ix::initNetSystem();
socket_.setUrl(url);
socket_.setExtraHeaders(headers);
socket_.disableAutomaticReconnection();
socket_.setOnMessageCallback([this](const ix::WebSocketMessagePtr& message) {
const std::lock_guard lock(mutex_);
switch (message->type) {
case ix::WebSocketMessageType::Open:
open_ = true;
break;
case ix::WebSocketMessageType::Message:
messages_.push_back(message->str);
break;
case ix::WebSocketMessageType::Close:
closed_ = true;
close_code_ = message->closeInfo.code;
break;
case ix::WebSocketMessageType::Error:
closed_ = true;
break;
default:
break;
}
changed_.notify_all();
});
socket_.start();
}
~TestClient()
{
socket_.stop();
ix::uninitNetSystem();
}
TestClient(const TestClient&) = delete;
TestClient& operator=(const TestClient&) = delete;
bool wait_open() { return wait([this] { return open_ || closed_; }) && open_; }
void send(const std::string& text) { socket_.sendText(text); }
void stop() { socket_.stop(); }
std::vector<std::string> wait_messages(std::size_t count)
{
wait([this, count] { return messages_.size() >= count || closed_; });
const std::lock_guard lock(mutex_);
return messages_;
}
// Envoie une requête et attend sa réponse (messages reçus jusqu'ici ignorés).
json call(const std::string& type, json payload = json::object())
{
std::size_t before = 0;
{
const std::lock_guard lock(mutex_);
before = messages_.size();
}
send(json{{"v", 1}, {"type", type}, {"id", type}, {"payload", std::move(payload)}}.dump());
const auto messages = wait_messages(before + 1);
REQUIRE(messages.size() > before);
return json::parse(messages[before]);
}
bool wait_closed() { return wait([this] { return closed_; }); }
std::uint16_t close_code()
{
const std::lock_guard lock(mutex_);
return close_code_;
}
private:
template <typename Predicate>
bool wait(Predicate predicate)
{
std::unique_lock lock(mutex_);
return changed_.wait_for(lock, timeout, predicate);
}
ix::WebSocket socket_;
std::mutex mutex_;
std::condition_variable changed_;
bool open_ = false;
bool closed_ = false;
std::uint16_t close_code_ = 0;
std::vector<std::string> messages_;
};
json handshake(TestClient& client, const char* who = "sequa-studio-host")
{
REQUIRE(client.wait_open());
client.send(encode_hello(protocol_version, who));
const auto messages = client.wait_messages(1);
REQUIRE(messages.size() == 1);
INFO("reponse : " << messages.front());
const json reply = json::parse(messages.front());
REQUIRE(reply["type"] == "welcome");
return reply;
}
} // namespace
TEST_CASE("Serveur : poignee de main 0.3 sur un vrai socket")
{
Fixture fixture;
TestClient client(fixture.url());
const json welcome = handshake(client);
CHECK(welcome["type"] == "welcome");
CHECK(welcome["payload"]["coreVersion"] == "9.9.9-test");
CHECK(welcome["payload"]["protocolVersion"] == "0.3.0");
CHECK(welcome["payload"]["instanceId"] == fixture.runtime->core_status().instance_id);
}
TEST_CASE("Serveur : jeton exige")
{
ServerOptions options;
options.token = "0123456789abcdef";
Fixture fixture(options);
SUBCASE("absent")
{
TestClient client(fixture.url());
const auto messages = client.wait_messages(1);
REQUIRE_FALSE(messages.empty());
CHECK(json::parse(messages.front())["payload"]["code"] == "unauthorized");
REQUIRE(client.wait_closed());
CHECK(client.close_code() == close_code::policy_violation);
}
SUBCASE("faux")
{
TestClient client(fixture.url("?token=0123456789abcdeX"));
REQUIRE(client.wait_closed());
CHECK(client.close_code() == close_code::policy_violation);
}
SUBCASE("correct, parmi d'autres parametres")
{
TestClient client(fixture.url("?x=1&token=0123456789abcdef"));
CHECK(handshake(client)["type"] == "welcome");
}
}
TEST_CASE("Serveur : origine verifiee, origine absente acceptee")
{
SUBCASE("origine locale par defaut")
{
Fixture fixture;
TestClient local(fixture.url(), {{"Origin", "http://localhost:5173"}});
CHECK(handshake(local, "sequa-studio")["type"] == "welcome");
}
SUBCASE("site etranger refuse")
{
Fixture fixture;
TestClient foreign(fixture.url(), {{"Origin", "https://exemple.com"}});
const auto messages = foreign.wait_messages(1);
REQUIRE_FALSE(messages.empty());
CHECK(json::parse(messages.front())["payload"]["code"] == "forbidden-origin");
REQUIRE(foreign.wait_closed());
CHECK(foreign.close_code() == close_code::policy_violation);
}
SUBCASE("liste explicite : seule l'origine exacte passe")
{
ServerOptions options;
options.allowed_origins = {"http://127.0.0.1:9180"};
Fixture fixture(options);
TestClient other_port(fixture.url(), {{"Origin", "http://127.0.0.1:5173"}});
REQUIRE(other_port.wait_closed());
TestClient exact(fixture.url(), {{"Origin", "http://127.0.0.1:9180"}});
CHECK(handshake(exact, "sequa-studio")["type"] == "welcome");
}
SUBCASE("client natif ws:// accepte meme avec une liste explicite")
{
ServerOptions options;
options.allowed_origins = {"http://127.0.0.1:9180"};
Fixture fixture(options);
TestClient native(fixture.url(), {{"Origin", "ws://127.0.0.1:1234"}});
CHECK(handshake(native)["type"] == "welcome");
}
SUBCASE("piege de prefixe")
{
Fixture fixture;
TestClient trap(fixture.url(), {{"Origin", "http://localhost.exemple.com"}});
REQUIRE(trap.wait_closed());
CHECK(trap.close_code() == close_code::policy_violation);
}
}
TEST_CASE("Serveur : export puis import d'une session par le protocole")
{
Fixture fixture;
TestClient client(fixture.url());
handshake(client);
CHECK(client.call("session/start", {{"seed", "2026"}})["payload"]["tick"] == 0);
CHECK(client.call("session/input", {{"buttons", 1}, {"moveX", 32767}, {"moveY", 0}})["payload"]["tick"] == 0);
// Pas de temps réel ici : la session n'avance pas toute seule, l'export est à tick 0.
const json exported = client.call("session/export", {{"includeHashes", true}})["payload"];
CHECK(exported["tick"] == 0);
const json imported = client.call("session/import", {{"recording", exported["recording"]}, {"resume", false}})["payload"];
CHECK(imported["running"] == false);
CHECK(imported["stateHash"] == exported["stateHash"]);
CHECK(client.call("session/import", {{"recording", "@@@@"}, {"resume", true}})["payload"]["code"] == "invalid-recording");
}
TEST_CASE("Serveur : bail - la deconnexion du controleur declenche l'arret")
{
auto runtime_options = Fixture::make_runtime_options();
runtime_options.linked = true;
Fixture fixture({}, runtime_options);
{
TestClient controller(fixture.url());
handshake(controller);
CHECK(controller.call("core/attach")["payload"]["linked"] == true);
CHECK(fixture.runtime->core_status().controller_attached);
controller.stop();
}
std::this_thread::sleep_for(300ms); // grâce de 100 ms
CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::lease_expired);
}
TEST_CASE("Serveur : bail leve par core/detach - pas d'arret")
{
auto runtime_options = Fixture::make_runtime_options();
runtime_options.linked = true;
Fixture fixture({}, runtime_options);
{
TestClient controller(fixture.url());
handshake(controller);
CHECK(controller.call("core/attach")["payload"]["linked"] == true);
CHECK(controller.call("core/detach")["payload"]["linked"] == false);
controller.stop();
}
std::this_thread::sleep_for(300ms);
CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::none);
}
TEST_CASE("Serveur : core/shutdown demande l'arret")
{
Fixture fixture;
TestClient client(fixture.url());
handshake(client);
CHECK(client.call("core/shutdown")["type"] == "core/shutdown");
std::this_thread::sleep_for(400ms);
CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::shutdown);
}
TEST_CASE("Serveur : port deja occupe -> start() renvoie une erreur")
{
Fixture first;
ServerOptions options;
options.port = first.server->options().port;
CoreServer second(options, *first.runtime);
CHECK_FALSE(second.start().empty());
CHECK_FALSE(second.running());
}