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).
321 lines
11 KiB
C++
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());
|
|
}
|