From f39c7c4a7253df0faa3ad48b3252c74096ce9fa3 Mon Sep 17 00:00:00 2001 From: Matt Jones <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:57:48 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20RELAY=20spec=20v0.2=20conformance=20?= =?UTF-8?q?=E2=80=94=20relay=20namespace,=20Channel,=20Adapt()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RELAY spec §18.2 C++ bindings for cpp-RCP (closes issue #5): - include/relay/relay.hpp: relay:: namespace with Protocol enum, Message envelope, Errc error category with ErrClosed/ErrNotConnected/ErrTimeout/ ErrPayloadTooLarge sentinels, Context, BackPressurePolicy, SubscriberOptions, Channel generic typed channel, Node and Caller abstract classes, and kRelaySpecVersion = "0.2" constant. - include/rcp/rcp.hpp: rcp::Context aliased to relay::Context; rcp::StatusChannel aliased to relay::Channel; rcp_category() gains equivalent() mapping rcp::Errc codes to relay::Errc conditions (§5.2, §5.3). - include/rcp/adapt.hpp: rcp::Adapt() wrapping Controller as relay::Caller; zone_to_relay_id/zone_from_relay_id helpers; status_to_message/ response_to_message/message_to_command conversions (§15.7.5). - tests/test_relay.cpp: 23 conformance tests covering spec version, Protocol enum values, all four mandatory error sentinels (§5.1), error_condition equivalence mappings (§5.2), Channel push/recv semantics, SubscriberOptions defaults (§14), Context alias, StatusChannel alias, and Adapt() call/send/ subscribe/close behaviour (§10.3). All 27/27 tests pass. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- include/rcp/adapt.hpp | 203 +++++++++++++++++++++++++++++++++++ include/rcp/rcp.hpp | 107 +++++-------------- include/relay/relay.hpp | 227 ++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 6 ++ tests/test_relay.cpp | 222 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 684 insertions(+), 81 deletions(-) create mode 100644 include/rcp/adapt.hpp create mode 100644 include/relay/relay.hpp create mode 100644 tests/test_relay.cpp diff --git a/include/rcp/adapt.hpp b/include/rcp/adapt.hpp new file mode 100644 index 0000000..463783c --- /dev/null +++ b/include/rcp/adapt.hpp @@ -0,0 +1,203 @@ +// fusa:req REQ-RELAY-001 +// fusa:req REQ-RELAY-002 +// fusa:req REQ-RELAY-003 +// fusa:req REQ-RELAY-004 +// fusa:req REQ-RELAY-005 + +// RELAY application interface adapter for cpp-RCP (§10.3, §18.2). +// +// Adapt() wraps an rcp::Controller as a relay::Caller so application code can +// use the protocol-agnostic relay::Node / relay::Caller interface and swap the +// underlying protocol with a single constructor change. +// +// Usage: +// auto ctrl = std::make_shared(rcp::Zone::FrontLeft); +// auto caller = rcp::Adapt(ctrl); // relay::Caller* +// auto [resp, ec] = caller->call(ctx, msg); +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace rcp { + +// ── Zone ↔ relay::Message.ID helpers (§15.7.5) ─────────────────────────────── + +// zone_to_relay_id returns the RELAY-canonical PascalCase zone name (§4.2). +inline std::string zone_to_relay_id(Zone z) { + switch (z) { + case Zone::FrontLeft: return "FrontLeft"; + case Zone::FrontRight: return "FrontRight"; + case Zone::RearLeft: return "RearLeft"; + case Zone::RearRight: return "RearRight"; + case Zone::Central: return "Central"; + default: return "Unknown"; + } +} + +// zone_from_relay_id parses a PascalCase zone name from relay::Message.ID. +inline Zone zone_from_relay_id(const std::string& id) { + if (id == "FrontLeft") return Zone::FrontLeft; + if (id == "FrontRight") return Zone::FrontRight; + if (id == "RearLeft") return Zone::RearLeft; + if (id == "RearRight") return Zone::RearRight; + if (id == "Central") return Zone::Central; + return Zone::Unknown; +} + +// priority_from_meta parses the "rcp.priority" meta key. +inline Priority priority_from_meta(const std::map& meta) { + auto it = meta.find("rcp.priority"); + if (it == meta.end()) return Priority::Normal; + if (it->second == "high") return Priority::High; + if (it->second == "critical") return Priority::Critical; + return Priority::Normal; +} + +// cmd_type_from_meta parses the "rcp.cmd_type" meta key. +inline CommandType cmd_type_from_meta(const std::map& meta) { + auto it = meta.find("rcp.cmd_type"); + if (it == meta.end()) return CommandType::Noop; + const auto& s = it->second; + if (s == "set") return CommandType::Set; + if (s == "get") return CommandType::Get; + if (s == "reset") return CommandType::Reset; + if (s == "watchdog") return CommandType::Watchdog; + if (s == "sleep") return CommandType::Sleep; + if (s == "wake") return CommandType::Wake; + return CommandType::Noop; +} + +// ── ToMessage / FromMessage (§15.7.5) ──────────────────────────────────────── + +// status_to_message converts rcp::Status to relay::Message (subscribe direction). +inline relay::Message status_to_message(const Status& s) { + relay::Message msg; + msg.protocol = relay::Protocol::RCP; + msg.id = zone_to_relay_id(s.zone); + msg.payload = s.payload; + msg.seq = s.seq; + msg.timestamp = std::chrono::system_clock::now(); + msg.meta["rcp.healthy"] = s.healthy ? "true" : "false"; + return msg; +} + +// response_to_message converts rcp::Response to relay::Message (call direction). +inline relay::Message response_to_message(const Response& r) { + relay::Message msg; + msg.protocol = relay::Protocol::RCP; + msg.id = zone_to_relay_id(r.zone); + msg.payload = r.payload; + msg.timestamp = std::chrono::system_clock::now(); + msg.meta["rcp.status"] = std::to_string(static_cast(r.status)); + return msg; +} + +// message_to_command converts a relay::Message to rcp::Command (call direction). +inline Command message_to_command(const relay::Message& msg) { + Command cmd; + cmd.zone = zone_from_relay_id(msg.id); + cmd.priority = priority_from_meta(msg.meta); + cmd.type = cmd_type_from_meta(msg.meta); + cmd.payload = msg.payload; + return cmd; +} + +// ── RcpCallerAdapter — implements relay::Caller over rcp::Controller ────────── + +class RcpCallerAdapter final : public relay::Caller { +public: + explicit RcpCallerAdapter(std::shared_ptr ctrl) + : ctrl_(std::move(ctrl)) {} + + relay::Protocol protocol() const noexcept override { + return relay::Protocol::RCP; + } + + // send maps relay::Message → rcp::Command, discards the response (§10.6). + std::error_code send(relay::Context ctx, const relay::Message& msg) override { + if (!ctrl_) return make_error_code(Errc::not_found); + Command cmd = message_to_command(msg); + cmd.zone = zone_from_relay_id(msg.id); + Response resp; + return ctrl_->send(ctx, cmd, resp); + } + + // call maps relay::Message → rcp::Command → relay::Message (§10.2). + std::pair + call(relay::Context ctx, const relay::Message& req) override { + if (!ctrl_) return {{}, make_error_code(Errc::not_found)}; + Command cmd = message_to_command(req); + Response resp; + auto ec = ctrl_->send(ctx, cmd, resp); + if (ec) return {{}, ec}; + return {response_to_message(resp), {}}; + } + + // subscribe wraps rcp::Controller::subscribe, forwarding Status as relay::Message. + // Starts a background thread per §10.5 that reads from StatusChannel and pushes + // to the relay::Channel. Thread exits when the status channel closes. + std::pair>, std::error_code> + subscribe(relay::SubscriberOptions opts = {}) override { + if (!ctrl_) return {nullptr, make_error_code(Errc::not_found)}; + + std::shared_ptr status_ch; + auto ec = ctrl_->subscribe(relay::Context::background(), status_ch); + if (ec) return {nullptr, ec}; + + auto out = std::make_shared>(opts.channel_depth); + auto bp = opts.back_pressure; + + std::thread([status_ch, out, bp]() mutable { + while (true) { + auto st = status_ch->recv(); + if (!st) break; + auto msg = status_to_message(*st); + switch (bp) { + case relay::BackPressurePolicy::drop_newest: + out->push(std::move(msg)); + break; + case relay::BackPressurePolicy::drop_oldest: + if (!out->push(msg)) { + out->try_recv(); + out->push(std::move(msg)); + } + break; + case relay::BackPressurePolicy::block: + while (!out->push(msg) && !out->is_closed()) { + // spin until space available or channel closed + } + break; + } + } + out->close(); + }).detach(); + + return {out, {}}; + } + + std::error_code close() noexcept override { + if (!ctrl_) return {}; + return ctrl_->close(); + } + +private: + std::shared_ptr ctrl_; +}; + +// ── Adapt() (§10.3) ────────────────────────────────────────────────────────── + +// Adapt wraps an rcp::Controller as a relay::Caller. +// The returned relay::Caller also satisfies relay::Node. +// Does NOT block or connect; wraps the given controller immediately. +inline std::unique_ptr Adapt(std::shared_ptr ctrl) { + return std::make_unique(std::move(ctrl)); +} + +} // namespace rcp diff --git a/include/rcp/rcp.hpp b/include/rcp/rcp.hpp index a0ba9b7..c6ca0a7 100644 --- a/include/rcp/rcp.hpp +++ b/include/rcp/rcp.hpp @@ -48,16 +48,18 @@ // A central high-performance computer uses a Registry to discover zone controllers, // then dispatches Commands to each Controller and receives Responses and Status // telemetry in return. +// +// RELAY conformance: include for relay:: namespace types, and +// for Adapt() which wraps a Controller as a relay::Caller. #pragma once +#include + #include #include -#include #include -#include #include #include -#include #include #include #include @@ -90,6 +92,20 @@ inline const std::error_category& rcp_category() noexcept { default: return "rcp: unknown error"; } } + // Map rcp::Errc codes to relay::Errc conditions (§5.2, §5.3). + bool equivalent(int code, const std::error_condition& cond) const noexcept override { + if (cond.category() != relay::relay_category()) return false; + auto re = static_cast(cond.value()); + switch (static_cast(code)) { + case Errc::closed: return re == relay::Errc::closed; + case Errc::timeout: return re == relay::Errc::timeout; + case Errc::busy: return re == relay::Errc::timeout; + case Errc::not_found: return re == relay::Errc::not_connected; + case Errc::zone_mismatch: return re == relay::Errc::not_connected; + case Errc::already_exists: return re == relay::Errc::closed; // §5.3; see RELAY issue #8 + default: return false; + } + } }; static Cat instance; return instance; @@ -225,87 +241,16 @@ class Loan { std::function release_; }; -// ── Context ─────────────────────────────────────────────────────────────────── -// Lightweight context carrying an optional deadline. Analogous to Go's context.Context. - -class Context { -public: - static Context background() noexcept { return Context{}; } - - static Context with_deadline(std::chrono::steady_clock::time_point tp) noexcept { - Context c; - c.deadline_ = tp; - return c; - } - - static Context with_timeout(std::chrono::steady_clock::duration d) noexcept { - return with_deadline(std::chrono::steady_clock::now() + d); - } - - bool done() const noexcept { - if (!deadline_) return false; - return std::chrono::steady_clock::now() >= *deadline_; - } - - std::optional deadline() const noexcept { - return deadline_; - } - -private: - std::optional deadline_; -}; - -// ── StatusChannel ───────────────────────────────────────────────────────────── -// Thread-safe bounded channel for Status updates, analogous to a Go channel. +// ── Context — relay::Context alias (§18.2) ──────────────────────────────────── +// rcp::Context is an alias for relay::Context per §18.2 conformance. -class StatusChannel { -public: - explicit StatusChannel(size_t cap = 16) : cap_(cap) {} - - void push(const Status& s) { - std::lock_guard lk(mu_); - if (closed_) return; - if (queue_.size() < cap_) { - queue_.push_back(s); - cv_.notify_one(); - } - } +using Context = relay::Context; - std::optional recv() { - std::unique_lock lk(mu_); - cv_.wait(lk, [this] { return !queue_.empty() || closed_; }); - if (queue_.empty()) return std::nullopt; - auto s = queue_.front(); - queue_.pop_front(); - return s; - } +// ── StatusChannel — relay::Channel alias (§18.2) ───────────────────── +// rcp::StatusChannel is an alias for relay::Channel. +// Legacy code using std::shared_ptr is unchanged. - std::optional try_recv() { - std::lock_guard lk(mu_); - if (queue_.empty()) return std::nullopt; - auto s = queue_.front(); - queue_.pop_front(); - return s; - } - - void close() { - std::lock_guard lk(mu_); - closed_ = true; - cv_.notify_all(); - } - - bool is_closed() const noexcept { - std::lock_guard lk(mu_); - return closed_; - } - -private: - mutable std::mutex mu_; - std::condition_variable cv_; - std::deque queue_; - size_t cap_; - bool closed_ = false; -}; +using StatusChannel = relay::Channel; // ── Controller ──────────────────────────────────────────────────────────────── diff --git a/include/relay/relay.hpp b/include/relay/relay.hpp new file mode 100644 index 0000000..8a0daff --- /dev/null +++ b/include/relay/relay.hpp @@ -0,0 +1,227 @@ +// RELAY C++ bindings — relay namespace types (RELAY spec §18.2, v0.2). +// +// Defines the protocol-agnostic relay:: namespace: Protocol, Message, Errc +// sentinels, Channel, Context, SubscriberOptions, Node, and Caller. +// cpp-RCP conformance: include this header, then use rcp::Adapt() (adapt.hpp) +// to wrap a Controller as a relay::Caller. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace relay { + +// ── Spec version (§19.4) ───────────────────────────────────────────────────── + +constexpr std::string_view kRelaySpecVersion = "0.2"; + +// ── Protocol identifiers (§3) ──────────────────────────────────────────────── + +enum class Protocol : int { + CAN = 1, + DDS = 2, + LIN = 3, + MQTT = 4, + RCP = 5, + SOMEIP = 6, +}; + +inline std::string to_string(Protocol p) { + switch (p) { + case Protocol::CAN: return "CAN"; + case Protocol::DDS: return "DDS"; + case Protocol::LIN: return "LIN"; + case Protocol::MQTT: return "MQTT"; + case Protocol::RCP: return "RCP"; + case Protocol::SOMEIP: return "SOMEIP"; + default: return "unknown"; + } +} + +// ── Version (§4.1) ─────────────────────────────────────────────────────────── + +struct Version { + int major = 0; + int minor = 0; + int patch = 0; +}; + +// ── Message — universal envelope (§4) ──────────────────────────────────────── + +struct Message { + Protocol protocol = Protocol::RCP; + Version version = {}; + std::string id; + std::vector payload; + std::chrono::system_clock::time_point timestamp = {}; + uint64_t seq = 0; + std::map meta; +}; + +// ── Error codes — mandatory sentinels (§5.1) ───────────────────────────────── + +enum class Errc { closed = 0, not_connected = 1, timeout = 2, payload_too_large = 3 }; + +inline const std::error_category& relay_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "relay"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case Errc::closed: return "relay: closed"; + case Errc::not_connected: return "relay: not connected"; + case Errc::timeout: return "relay: timeout"; + case Errc::payload_too_large: return "relay: payload too large"; + default: return "relay: unknown"; + } + } + }; + static Cat inst; + return inst; +} + +inline std::error_condition make_error_condition(Errc e) noexcept { + return {static_cast(e), relay_category()}; +} + +// Sentinel error conditions (§5.1). Use error_condition (not error_code) so +// protocol-specific codes can map to these via std::error_condition equivalence. +inline const std::error_condition ErrClosed = make_error_condition(Errc::closed); +inline const std::error_condition ErrNotConnected = make_error_condition(Errc::not_connected); +inline const std::error_condition ErrTimeout = make_error_condition(Errc::timeout); +inline const std::error_condition ErrPayloadTooLarge = make_error_condition(Errc::payload_too_large); + +// ── Context (§18.2) ────────────────────────────────────────────────────────── + +class Context { +public: + static Context background() noexcept { return Context{}; } + + static Context with_deadline(std::chrono::steady_clock::time_point tp) noexcept { + Context c; + c.deadline_ = tp; + return c; + } + + static Context with_timeout(std::chrono::steady_clock::duration d) noexcept { + return with_deadline(std::chrono::steady_clock::now() + d); + } + + bool done() const noexcept { + if (!deadline_) return false; + return std::chrono::steady_clock::now() >= *deadline_; + } + + std::optional deadline() const noexcept { + return deadline_; + } + +private: + std::optional deadline_; +}; + +// ── BackPressurePolicy and SubscriberOptions (§18.2) ───────────────────────── + +enum class BackPressurePolicy { drop_newest = 0, drop_oldest = 1, block = 2 }; + +struct SubscriberOptions { + std::size_t channel_depth = 64; + BackPressurePolicy back_pressure = BackPressurePolicy::drop_newest; +}; + +// ── Channel — typed bounded channel (§18.2) ─────────────────────────────── +// +// push() is safe to call from a single writer thread concurrently with recv(). +// Multiple concurrent writers MUST be serialised externally (§18.2 note). +// Returns true if enqueued; false if full or closed. + +template +class Channel { +public: + explicit Channel(std::size_t capacity = 64) : cap_(capacity) {} + + bool push(T value) { + std::lock_guard lk(mu_); + if (closed_) return false; + if (queue_.size() >= cap_) return false; + queue_.push_back(std::move(value)); + cv_.notify_one(); + return true; + } + + std::optional recv() { + std::unique_lock lk(mu_); + cv_.wait(lk, [this] { return !queue_.empty() || closed_; }); + if (queue_.empty()) return std::nullopt; + T v = std::move(queue_.front()); + queue_.pop_front(); + return v; + } + + std::optional try_recv() { + std::lock_guard lk(mu_); + if (queue_.empty()) return std::nullopt; + T v = std::move(queue_.front()); + queue_.pop_front(); + return v; + } + + void close() noexcept { + std::lock_guard lk(mu_); + closed_ = true; + cv_.notify_all(); + } + + bool is_closed() const noexcept { + std::lock_guard lk(mu_); + return closed_; + } + +private: + mutable std::mutex mu_; + std::condition_variable cv_; + std::deque queue_; + std::size_t cap_; + bool closed_ = false; +}; + +// ── Node and Caller abstract classes (§18.2) ───────────────────────────────── +// +// Note: subscribe() returns shared_ptr> rather than the spec's +// Channel by value because Channel contains a non-movable std::mutex. +// See RELAY issue #3. The shared ownership model aligns with cpp-RCP's existing +// StatusChannel usage pattern. + +class Node { +public: + virtual Protocol protocol() const noexcept = 0; + virtual std::error_code send(Context ctx, const Message& msg) = 0; + virtual std::pair>, std::error_code> + subscribe(SubscriberOptions opts = {}) = 0; + virtual std::error_code close() noexcept = 0; + virtual ~Node() = default; +}; + +class Caller : public Node { +public: + virtual std::pair + call(Context ctx, const Message& req) = 0; +}; + +} // namespace relay + +// Enable std::error_condition construction from relay::Errc. +namespace std { +template <> +struct is_error_condition_enum : true_type {}; +} // namespace std diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a184ac0..724b6ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,3 +106,9 @@ add_executable(command_latency_test command_latency_test.cpp) target_link_libraries(command_latency_test PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_latency COMMAND command_latency_test) set_tests_properties(rcp_latency PROPERTIES TIMEOUT 60) + +# ── RELAY conformance ───────────────────────────────────────────────────────── + +add_executable(test_relay test_relay.cpp) +target_link_libraries(test_relay PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_relay COMMAND test_relay) diff --git a/tests/test_relay.cpp b/tests/test_relay.cpp new file mode 100644 index 0000000..dec1aeb --- /dev/null +++ b/tests/test_relay.cpp @@ -0,0 +1,222 @@ +// fusa:req REQ-RELAY-001 +// fusa:req REQ-RELAY-002 +// fusa:req REQ-RELAY-003 +// fusa:req REQ-RELAY-004 +// fusa:req REQ-RELAY-005 + +// RELAY conformance tests (RELAY spec §18.2, §5.1, §5.2, §10.3, §14, §19.4). +// +// Verifies that cpp-RCP satisfies the mandatory RELAY-conformance requirements +// as enumerated in Appendix A of the RELAY spec. +#include + +#include +#include +#include + +#include +#include + +using namespace std::chrono_literals; + +// ── §19.4: SpecVersion constant ─────────────────────────────────────────────── + +TEST_CASE("relay: kRelaySpecVersion is 0.2", "[relay][conformance]") { + REQUIRE(relay::kRelaySpecVersion == "0.2"); +} + +// ── §3: Protocol enum ───────────────────────────────────────────────────────── + +TEST_CASE("relay: Protocol enum values match spec §3", "[relay][conformance]") { + REQUIRE(static_cast(relay::Protocol::CAN) == 1); + REQUIRE(static_cast(relay::Protocol::DDS) == 2); + REQUIRE(static_cast(relay::Protocol::LIN) == 3); + REQUIRE(static_cast(relay::Protocol::MQTT) == 4); + REQUIRE(static_cast(relay::Protocol::RCP) == 5); + REQUIRE(static_cast(relay::Protocol::SOMEIP) == 6); +} + +// ── §5.1: Mandatory error sentinels ─────────────────────────────────────────── + +TEST_CASE("relay: mandatory error sentinels exist", "[relay][conformance]") { + REQUIRE(relay::ErrClosed.category() == relay::relay_category()); + REQUIRE(relay::ErrNotConnected.category() == relay::relay_category()); + REQUIRE(relay::ErrTimeout.category() == relay::relay_category()); + REQUIRE(relay::ErrPayloadTooLarge.category() == relay::relay_category()); +} + +TEST_CASE("relay: sentinel error category name is 'relay'", "[relay][conformance]") { + REQUIRE(std::string(relay::relay_category().name()) == "relay"); +} + +TEST_CASE("relay: sentinel messages match spec §5.1", "[relay][conformance]") { + REQUIRE(relay::ErrClosed.message() == "relay: closed"); + REQUIRE(relay::ErrNotConnected.message() == "relay: not connected"); + REQUIRE(relay::ErrTimeout.message() == "relay: timeout"); + REQUIRE(relay::ErrPayloadTooLarge.message() == "relay: payload too large"); +} + +// ── §5.2: rcp::Errc → relay::Errc std::error_condition equivalence ──────────── + +TEST_CASE("relay: rcp::ErrClosed maps to relay::ErrClosed", "[relay][conformance]") { + REQUIRE(rcp::ErrClosed == relay::ErrClosed); +} + +TEST_CASE("relay: rcp::ErrTimeout maps to relay::ErrTimeout", "[relay][conformance]") { + REQUIRE(rcp::ErrTimeout == relay::ErrTimeout); +} + +TEST_CASE("relay: rcp::ErrBusy maps to relay::ErrTimeout", "[relay][conformance]") { + REQUIRE(rcp::ErrBusy == relay::ErrTimeout); +} + +TEST_CASE("relay: rcp::ErrNotFound maps to relay::ErrNotConnected", "[relay][conformance]") { + REQUIRE(rcp::ErrNotFound == relay::ErrNotConnected); +} + +TEST_CASE("relay: rcp::ErrZoneMismatch maps to relay::ErrNotConnected", "[relay][conformance]") { + REQUIRE(rcp::ErrZoneMismatch == relay::ErrNotConnected); +} + +// ── §18.2: Context aliased from relay::Context ─────────────────────────────── + +TEST_CASE("relay: rcp::Context is relay::Context", "[relay][conformance]") { + static_assert(std::is_same::value, + "rcp::Context must be an alias for relay::Context (§18.2)"); + auto ctx = rcp::Context::with_timeout(100ms); + REQUIRE_FALSE(ctx.done()); +} + +// ── §18.2: StatusChannel aliased from relay::Channel ───────────────── + +TEST_CASE("relay: rcp::StatusChannel is relay::Channel", "[relay][conformance]") { + static_assert( + std::is_same>::value, + "rcp::StatusChannel must be relay::Channel (§18.2)"); + auto ch = std::make_shared(4); + rcp::Status s; + s.zone = rcp::Zone::FrontLeft; + s.healthy = true; + REQUIRE(ch->push(s)); + auto got = ch->try_recv(); + REQUIRE(got.has_value()); + REQUIRE(got->zone == rcp::Zone::FrontLeft); +} + +// ── §14: BackPressurePolicy default is drop_newest ─────────────────────────── + +TEST_CASE("relay: SubscriberOptions default channel depth is 64", "[relay][conformance]") { + relay::SubscriberOptions opts; + REQUIRE(opts.channel_depth == 64); + REQUIRE(opts.back_pressure == relay::BackPressurePolicy::drop_newest); +} + +// ── §18.2: Channel push returns false when full ─────────────────────────── + +TEST_CASE("relay: Channel push returns false when full", "[relay][channel]") { + relay::Channel ch(2); + REQUIRE(ch.push(1)); + REQUIRE(ch.push(2)); + REQUIRE_FALSE(ch.push(3)); // full +} + +TEST_CASE("relay: Channel push returns false after close", "[relay][channel]") { + relay::Channel ch(8); + ch.close(); + REQUIRE_FALSE(ch.push(1)); +} + +TEST_CASE("relay: Channel recv returns nullopt after close with empty queue", "[relay][channel]") { + relay::Channel ch(8); + ch.close(); + REQUIRE_FALSE(ch.recv().has_value()); +} + +// ── §10.3: Adapt() wraps Controller as relay::Caller ───────────────────────── + +TEST_CASE("relay: Adapt() returns non-null relay::Caller", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + REQUIRE(caller != nullptr); +} + +TEST_CASE("relay: Adapt() protocol() returns RCP", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + REQUIRE(caller->protocol() == relay::Protocol::RCP); +} + +TEST_CASE("relay: Adapt() call() sends command and returns response", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + + relay::Message req; + req.protocol = relay::Protocol::RCP; + req.id = "FrontLeft"; + req.meta["rcp.cmd_type"] = "get"; + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE_FALSE(ec); + REQUIRE(resp.id == "FrontLeft"); + REQUIRE(resp.protocol == relay::Protocol::RCP); +} + +TEST_CASE("relay: Adapt() send() succeeds", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + + relay::Message msg; + msg.id = "FrontLeft"; + msg.meta["rcp.cmd_type"] = "set"; + + auto ctx = relay::Context::with_timeout(1s); + REQUIRE_FALSE(caller->send(ctx, msg)); +} + +TEST_CASE("relay: Adapt() subscribe() returns valid channel", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + + auto [ch, ec] = caller->subscribe(); + REQUIRE_FALSE(ec); + REQUIRE(ch != nullptr); + + // Publish a status and verify it arrives via the relay channel. + ctrl->publish({0x01, 0x02}); + + relay::Message msg; + bool got = false; + auto deadline = std::chrono::steady_clock::now() + 500ms; + while (std::chrono::steady_clock::now() < deadline) { + auto m = ch->try_recv(); + if (m) { + msg = *m; + got = true; + break; + } + std::this_thread::sleep_for(1ms); + } + + REQUIRE(got); + REQUIRE(msg.protocol == relay::Protocol::RCP); + REQUIRE(msg.id == "FrontLeft"); + REQUIRE(msg.meta.count("rcp.healthy") == 1); +} + +TEST_CASE("relay: Adapt() close() idempotent", "[relay][adapt]") { + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto caller = rcp::Adapt(ctrl); + REQUIRE_FALSE(caller->close()); + REQUIRE_FALSE(caller->close()); // second call must be no-op +} + +// ── §15.7.5: zone_to_relay_id / zone_from_relay_id round-trips ─────────────── + +TEST_CASE("relay: zone round-trips through relay ID", "[relay][conform]") { + using rcp::Zone; + for (auto z : {Zone::FrontLeft, Zone::FrontRight, + Zone::RearLeft, Zone::RearRight, Zone::Central}) { + REQUIRE(rcp::zone_from_relay_id(rcp::zone_to_relay_id(z)) == z); + } +}