diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 4bb7884..b9d908c 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2109,7 +2109,7 @@ { "id": "REQ-RELAY-005", "title": "RELAY core bindings and RCP message mapping (\u00a718.2, \u00a714, \u00a715.7.5)", - "text": "rcp::Context shall alias relay::Context; SubscriberOptions shall default channel_depth=64 and expose topic_name; endpoint_id_to_relay_id()/relay_id_to_endpoint_id() shall round-trip a byte_bus_id through relay::Message.id as its plain decimal string form (RELAY spec v2.0 \u00a715.7.5), and message_to_request()/response_to_message() shall correctly decode/encode the op, evt_op, and response_kind fields.", + "text": "rcp::Context shall alias relay::Context; SubscriberOptions shall default channel_depth=64 and expose topic_name; endpoint_id_to_relay_id()/relay_id_to_endpoint_id() shall round-trip a byte_bus_id through relay::Message.id as its plain decimal string form (RELAY spec v2.0 \u00a715.7.5), and message_to_request()/response_to_message() shall correctly decode/encode the op, evt_op, read_size_or_segment_num, and response_kind fields.", "standard": "relay", "level": "QM", "asil": "QM" diff --git a/include/rcp/adapt.hpp b/include/rcp/adapt.hpp index 6d2ec98..3745ecb 100644 --- a/include/rcp/adapt.hpp +++ b/include/rcp/adapt.hpp @@ -52,6 +52,28 @@ // this file encoded ":" // into Message.id, which does not match the spec's plain decimal-string // form and would have broken interop with anything expecting it. +// +// ── Phase 4 rewrite (cpp-RCP issue #129) — genuine content-drift fix found ── +// A prior scoping pass characterized this header's generic RequestFn +// passthrough design as fully behaviorally equivalent to c-RCP's much +// richer per-endpoint-type rcp_adapt_op_t/field-table model +// (c-RCP's include/rcp/adapt.h + src/adapt.c, this project's content +// source of truth for this module). A line-by-line behavioral comparison +// against every one of that file's 13 endpoint-type op-mappings confirmed +// the *shape* of that claim — this header's op/evt_op meta fields and raw +// `payload` passthrough do correctly generalize c-RCP's per-op GPIO/SPI/ +// PWM_OUT channel-and-write-semantics handling (evt[2:0] is one shared ACF +// wire field regardless of which endpoint type is using it) — but found +// one real, load-bearing omission: this header never threaded the ACF +// header's own read_size_or_segment_num field through message_to_request()/ +// response_to_message() at all, silently defaulting it to 0 for every +// relay::Message. c-RCP's own adapt.c threads the identical wire field +// through this same bridging layer via rcp.uart.read_size/rcp.spi. +// read_size/rcp.adc.read_size/rcp.i2c.read_size/rcp.iseled.read_size — +// five of its per-op table's rows depend on it, and for I2C/ISELED it is +// the ONLY thing that selects the read vs. write direction. Fixed below +// via a single generic "rcp.read_size" meta key (read_size_from_meta()) — +// see that function's own doc comment for the full citation trail. #pragma once #include @@ -116,6 +138,51 @@ inline uint8_t evt_op_from_meta(const std::map& meta) return static_cast(v); } +// read_size_from_meta parses the "rcp.read_size" meta key (decimal 0-4095; +// default 0) — the ACF header's own 12-bit read_size_or_segment_num field +// (rcp/acf.hpp's AcfMessageInfo::read_size_or_segment_num). +// +// FIX (found during the cpp-RCP issue #129 c-RCP adapt.c/.h behavioral +// comparison): this helper, and the two call sites below that use it, were +// missing entirely before this pass — message_to_request() populated only +// op and evt_op from meta, silently leaving read_size_or_segment_num at +// AcfMessageInfo's own default of 0 for every relay::Message, with no way +// for a caller to override it. That is a real, load-bearing gap, not a +// cosmetic one: read_size_or_segment_num is the wire field a standard +// request's own read length rides on for every endpoint type that needs +// one — rcp/uart.hpp's encode_read_request ("read_size rides the ACF +// header's own read_size_or_segment_num field", uart.hpp kMaxReadSize = +// 0x0FFFu), rcp/spi.hpp's encode_transfer_request (read_size combines with +// the payload length via transfer_length()), rcp/adc.hpp's +// encode_read_request, and rcp/i2c.hpp's encode_transfer_request (where +// read_size == 0 IS the write direction and read_size != 0 selects the +// read direction — i.e. without this fix an I2C_TRANSFER built via Adapt() +// could never select the read direction at all). c-RCP's own adapt.c +// (this project's content source of truth for this module) threads the +// exact same wire field through this same bridging layer under distinct +// per-endpoint-type meta keys — rcp.uart.read_size (required, src/adapt.c +// rcp_message_to_request()'s RCP_ADAPT_OP_UART_READ case), +// rcp.spi.read_size (default = payload length, RCP_ADAPT_OP_SPI_TRANSFER +// case), rcp.adc.read_size (default = one value's worth, +// RCP_ADAPT_OP_ADC_READ case), and rcp.i2c.read_size / +// rcp.iseled.read_size (default 0 = write direction, RCP_ADAPT_OP_I2C_ +// TRANSFER / _ISELED_COMMAND cases) — because, unlike this generic +// passthrough design, c-RCP's own per-op field table needs one key per +// endpoint-type family. A single generic "rcp.read_size" key is enough +// here because read_size_or_segment_num is one wire field shared by every +// endpoint type that has one, not a per-type concept — matching how +// "rcp.evt_op" above already covers GPIO_WRITE's evt, PWM_OUT_WRITE's evt, +// and SPI_TRANSFER's channel select generically, since all three are the +// same evt[2:0] wire field too. +inline uint16_t read_size_from_meta(const std::map& meta) { + auto it = meta.find("rcp.read_size"); + if (it == meta.end()) return 0; + unsigned v = 0; + auto [p, ec] = std::from_chars(it->second.data(), it->second.data() + it->second.size(), v, 10); + if (ec != std::errc{} || p != it->second.data() + it->second.size() || v > 0x0FFFu) return 0; + return static_cast(v); +} + // ── ToMessage / FromMessage (§15.7.5) ──────────────────────────────────────── // response_to_message converts an ACF response (rcp/acf.hpp) into a @@ -129,6 +196,11 @@ inline relay::Message response_to_message(const acf::AcfMessageInfo& resp, msg.timestamp = std::chrono::system_clock::now(); msg.meta["rcp.response_kind"] = std::to_string(static_cast(acf::response_kind_of(resp))); msg.meta["rcp.err"] = resp.err ? "true" : "false"; + // rcp.read_size echoes resp.read_size_or_segment_num — see + // read_size_from_meta()'s own doc comment for why this field matters + // (UART/SPI/ADC/I2C/ISELED read length and, for I2C/ISELED, direction + // selection) and why a request-side omission of it was a real bug. + msg.meta["rcp.read_size"] = std::to_string(resp.read_size_or_segment_num); return msg; } @@ -143,6 +215,10 @@ inline bool message_to_request(const relay::Message& msg, out_info.byte_bus_id = byte_bus_id; out_info.op = op_from_meta(msg.meta); out_info.evt_op = evt_op_from_meta(msg.meta); + // read_size_or_segment_num — see read_size_from_meta()'s own doc + // comment for why this was missing before this pass and why that was a + // genuine behavioral gap, not a cosmetic one. + out_info.read_size_or_segment_num = read_size_from_meta(msg.meta); out_payload = msg.payload; return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 942538d..ecc5b24 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,10 @@ 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) +add_executable(test_adapt test_adapt.cpp) +target_link_libraries(test_adapt PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_adapt COMMAND test_adapt) + add_executable(test_cli test_cli.cpp) target_link_libraries(test_cli PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_cli COMMAND test_cli) diff --git a/tests/test_adapt.cpp b/tests/test_adapt.cpp new file mode 100644 index 0000000..edfd3ca --- /dev/null +++ b/tests/test_adapt.cpp @@ -0,0 +1,508 @@ +// fusa:test REQ-RELAY-001 +// fusa:test REQ-RELAY-002 +// fusa:test REQ-RELAY-003 +// fusa:test REQ-RELAY-004 +// fusa:test REQ-RELAY-005 + +// rcp/adapt.hpp conformance and behavioral-equivalence tests (RELAY spec +// §10.3, §15.7.5) — cpp-RCP issue #129, ROADMAP.md Phase 17 ("Phase 4") +// rewrite. +// +// This file's own scope, and how it relates to test_relay.cpp: test_relay.cpp +// (pre-existing) already covers RELAY §18.2/§5.1/§5.2/§19.4 core-conformance +// checks (Protocol enum, error sentinels, Context alias, Channel) plus a +// handful of top-level Adapt()/message_to_request()/response_to_message() +// smoke tests. This file goes deeper on rcp/adapt.hpp specifically — the +// meta<->AcfMessageInfo field mapping this header owns — and is the direct +// product of a line-by-line behavioral comparison against c-RCP's +// include/rcp/adapt.h + src/adapt.c (this project's content source of truth +// for this module, tests/test_adapt.c its test-coverage source of truth). +// +// That comparison found this header's generic RequestFn-passthrough design +// (Adapt()/RcpCallerAdapter, op/evt_op meta + raw payload) IS behaviorally +// equivalent to c-RCP's much richer per-endpoint-type rcp_adapt_op_t +// field-table design for every op whose translation this header is actually +// responsible for -- evt[2:0] is one shared ACF wire field regardless of +// which endpoint type reuses it (GPIO/PWM_OUT write-semantics, SPI channel +// select), and MDIO/CAN/wakeup/discovery's own endpoint-specific fields are +// packed into the opaque `payload` region by each ep-type module's own codec +// (rcp/mdio.hpp etc.), not by this bridging layer, in cpp-RCP's split +// architecture -- WITH ONE EXCEPTION: read_size_or_segment_num, the ACF +// header's own 12-bit read-length field, was never threaded through +// message_to_request()/response_to_message() at all before this pass. That +// is a genuine, load-bearing gap (c-RCP's own per-op table threads the +// identical wire field through 5 of its rows: rcp.uart.read_size/rcp.spi. +// read_size/rcp.adc.read_size/rcp.i2c.read_size/rcp.iseled.read_size) -- +// fixed in rcp/adapt.hpp via a single generic "rcp.read_size" meta key +// (read_size_from_meta()). Several TEST_CASEs below are direct regression +// coverage for that fix, translated from c-RCP's test_adapt.c's own +// test_uart_read_request_*/test_i2c_transfer_*_read_size* cases into +// equivalence-of-outcome checks against this header's simpler API (a single +// generic meta key standing in for c-RCP's five per-endpoint-type ones), +// not a line-by-line port of tests that assume c-RCP's own rcp_adapt_op_t +// internal structure. +// +// Test cases c-RCP's test_adapt.c has that this file deliberately does NOT +// port, because they exercise concepts with no cpp-RCP counterpart in this +// header's own simpler design (see rcp/adapt.hpp's own "Phase 4 rewrite" +// header-comment section for the full architecture comparison): +// - rcp_adapt_op_t / rcp_adapt_op_kind() / rcp_adapt_op_string() round +// trips: this header has no per-op opcode enum at all -- byte_bus_id +// alone selects the destination, exactly as every other RequestFn-based +// bridge in this codebase (record.hpp, observe.hpp) already does. +// - MDIO addr/word_count, CAN frame_format/arbitration_id, and discovery's +// own magic/svr_version/vendor_id/device_id/svr_ep_count field mapping: +// none of these are AcfMessageInfo fields in cpp-RCP's split +// architecture -- they are packed into the opaque `payload` region by +// rcp/mdio.hpp's own codec (or, for CAN/discovery, are still-open scope +// gaps in rcp/can.hpp/rcp/mock.hpp's own dispatch wiring, not this +// header's problem to solve -- see this file's own "payload passthrough +// is opaque" section below for what IS tested instead). +// - wire-level encode/decode failure propagation (rcp_message_to_request's +// RCP_ADAPT_ERR_ENCODE / rcp_response_to_message's RCP_ADAPT_ERR_DECODE +// for a malformed frame): this header never touches wire bytes itself +// -- it maps meta<->AcfMessageInfo fields only, so there is no encode/ +// decode step here that can fail this way. A RequestFn's own failure +// (whatever a real wire codec inside it reports) is covered generically +// by this file's "call()/send() propagate the wrapped RequestFn's own +// error" cases below. +// - rcp_relay_caller_t manual retain/refcount and rcp_adapt()'s own +// transport-binding/transaction-numbering (avtp transport, sequence +// numbers): rcp::RcpCallerAdapter owns no transport and assigns no +// transaction numbers itself -- see rcp/adapt.hpp's own header comment +// on why "there is no unified client-side send() chokepoint left to +// wrap" (v2.14.0). std::unique_ptr already gives Adapt()'s return value +// RAII lifetime with no manual retain/release protocol to test. +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace std::chrono_literals; + +namespace { +std::shared_ptr make_configured_mock_server() { + auto srv = std::make_shared(); + srv->advance_to_rcp_configured(); + return srv; +} + +rcp::RequestFn mock_request_fn(std::shared_ptr srv) { + return [srv](const rcp::Context&, const rcp::acf::AcfMessageInfo& req, + const std::vector& payload, + rcp::acf::AcfMessageInfo& out, std::vector& out_payload) { + return srv->dispatch(0, req, payload, out, out_payload); + }; +} +} // namespace + +// ── op_from_meta ────────────────────────────────────────────────────────── + +TEST_CASE("adapt: op_from_meta defaults to read (false) when rcp.op is absent", + "[adapt][meta]") { + std::map meta; + REQUIRE_FALSE(rcp::op_from_meta(meta)); +} + +TEST_CASE("adapt: op_from_meta reports write only for the exact string \"write\"", + "[adapt][meta]") { + std::map meta; + meta["rcp.op"] = "write"; + REQUIRE(rcp::op_from_meta(meta)); + + meta["rcp.op"] = "read"; + REQUIRE_FALSE(rcp::op_from_meta(meta)); + + meta["rcp.op"] = "Write"; // case-sensitive, not "write" + REQUIRE_FALSE(rcp::op_from_meta(meta)); + + meta["rcp.op"] = ""; + REQUIRE_FALSE(rcp::op_from_meta(meta)); +} + +// ── evt_op_from_meta ────────────────────────────────────────────────────── + +TEST_CASE("adapt: evt_op_from_meta defaults to 0 when rcp.evt_op is absent", + "[adapt][meta]") { + std::map meta; + REQUIRE(rcp::evt_op_from_meta(meta) == 0); +} + +TEST_CASE("adapt: evt_op_from_meta parses every value in its 0-7 range", "[adapt][meta]") { + std::map meta; + for (unsigned v = 0; v <= 7; ++v) { + meta["rcp.evt_op"] = std::to_string(v); + REQUIRE(rcp::evt_op_from_meta(meta) == static_cast(v)); + } +} + +TEST_CASE("adapt: evt_op_from_meta defaults to 0 for a value above 7", "[adapt][meta]") { + std::map meta; + meta["rcp.evt_op"] = "8"; + REQUIRE(rcp::evt_op_from_meta(meta) == 0); +} + +TEST_CASE("adapt: evt_op_from_meta defaults to 0 for trailing garbage or an empty value", + "[adapt][meta]") { + std::map meta; + meta["rcp.evt_op"] = "3x"; + REQUIRE(rcp::evt_op_from_meta(meta) == 0); + + meta["rcp.evt_op"] = ""; + REQUIRE(rcp::evt_op_from_meta(meta) == 0); + + meta["rcp.evt_op"] = "-1"; + REQUIRE(rcp::evt_op_from_meta(meta) == 0); +} + +// ── read_size_from_meta (NEW — this pass's own fix) ────────────────────── +// Direct regression coverage for the gap this pass found and fixed: before +// this fix, message_to_request() never populated +// AcfMessageInfo::read_size_or_segment_num from any meta key at all, so a +// relay::Message could never carry a non-zero UART/SPI/ADC/I2C/ISELED read +// length through Adapt() -- see rcp/adapt.hpp's own "Phase 4 rewrite" +// header-comment section and read_size_from_meta()'s own doc comment for +// the full citation trail against c-RCP's src/adapt.c. + +TEST_CASE("adapt: read_size_from_meta defaults to 0 when rcp.read_size is absent", + "[adapt][meta][read_size]") { + std::map meta; + REQUIRE(rcp::read_size_from_meta(meta) == 0); +} + +TEST_CASE("adapt: read_size_from_meta parses a valid decimal value", "[adapt][meta][read_size]") { + std::map meta; + meta["rcp.read_size"] = "128"; + REQUIRE(rcp::read_size_from_meta(meta) == 128); +} + +TEST_CASE("adapt: read_size_from_meta accepts the ACF header's own 12-bit boundary (4095)", + "[adapt][meta][read_size]") { + std::map meta; + meta["rcp.read_size"] = "4095"; + REQUIRE(rcp::read_size_from_meta(meta) == 4095); +} + +TEST_CASE("adapt: read_size_from_meta defaults to 0 for a value above the 12-bit bound (4096)", + "[adapt][meta][read_size]") { + std::map meta; + meta["rcp.read_size"] = "4096"; + REQUIRE(rcp::read_size_from_meta(meta) == 0); +} + +TEST_CASE("adapt: read_size_from_meta defaults to 0 for trailing garbage or an empty value", + "[adapt][meta][read_size]") { + std::map meta; + meta["rcp.read_size"] = "10x"; + REQUIRE(rcp::read_size_from_meta(meta) == 0); + + meta["rcp.read_size"] = ""; + REQUIRE(rcp::read_size_from_meta(meta) == 0); + + meta["rcp.read_size"] = "-5"; + REQUIRE(rcp::read_size_from_meta(meta) == 0); +} + +// ── endpoint_id_to_relay_id / relay_id_to_endpoint_id round-trips ───────── +// (test_relay.cpp already covers the spec-example/rejection cases; this +// file adds the boundary values relevant to this header's own encode path.) + +TEST_CASE("adapt: byte_bus_id 0 and 255 round-trip through relay id", "[adapt][id]") { + for (rcp::avtp::ByteBusId bus_id : {rcp::avtp::ByteBusId{0}, rcp::avtp::ByteBusId{255}}) { + auto id = rcp::endpoint_id_to_relay_id(bus_id); + rcp::avtp::ByteBusId decoded = 0; + REQUIRE(rcp::relay_id_to_endpoint_id(id, decoded)); + REQUIRE(decoded == bus_id); + } +} + +// ── message_to_request: full field mapping ──────────────────────────────── + +TEST_CASE("adapt: message_to_request rejects a message whose id doesn't decode", + "[adapt][message_to_request]") { + relay::Message msg; + msg.id = "not-a-number"; + rcp::acf::AcfMessageInfo req; + std::vector payload; + REQUIRE_FALSE(rcp::message_to_request(msg, req, payload)); +} + +TEST_CASE("adapt: message_to_request maps byte_bus_id/op/evt_op/read_size/payload together", + "[adapt][message_to_request][read_size]") { + relay::Message msg; + msg.id = rcp::endpoint_id_to_relay_id(42); + msg.meta["rcp.op"] = "write"; + msg.meta["rcp.evt_op"] = "6"; + msg.meta["rcp.read_size"] = "300"; + msg.payload = {0xDE, 0xAD, 0xBE, 0xEF}; + + rcp::acf::AcfMessageInfo req; + std::vector payload; + REQUIRE(rcp::message_to_request(msg, req, payload)); + REQUIRE(req.byte_bus_id == 42); + REQUIRE(req.op == true); + REQUIRE(req.evt_op == 6); + REQUIRE(req.read_size_or_segment_num == 300); // the fix under test + REQUIRE(payload == std::vector{0xDE, 0xAD, 0xBE, 0xEF}); +} + +TEST_CASE("adapt: message_to_request leaves read_size_or_segment_num at 0 when absent " + "(matches AcfMessageInfo's own default, not a silent misparse)", + "[adapt][message_to_request][read_size]") { + relay::Message msg; + msg.id = rcp::endpoint_id_to_relay_id(1); + + rcp::acf::AcfMessageInfo req; + std::vector payload; + REQUIRE(rcp::message_to_request(msg, req, payload)); + REQUIRE(req.read_size_or_segment_num == 0); +} + +// ── response_to_message: full field mapping ─────────────────────────────── + +TEST_CASE("adapt: response_to_message echoes read_size_or_segment_num as rcp.read_size", + "[adapt][response_to_message][read_size]") { + rcp::acf::AcfMessageInfo resp; + resp.byte_bus_id = 9; + resp.rsp = true; + resp.read_size_or_segment_num = 17; + + auto msg = rcp::response_to_message(resp, {}); + REQUIRE(msg.meta.at("rcp.read_size") == "17"); +} + +TEST_CASE("adapt: response_to_message reports rcp.err for an error response", + "[adapt][response_to_message]") { + rcp::acf::AcfMessageInfo resp; + resp.byte_bus_id = 2; + resp.rsp = true; + resp.err = true; + + auto msg = rcp::response_to_message(resp, {}); + REQUIRE(msg.meta.at("rcp.err") == "true"); +} + +// ── UART_READ end-to-end via mock::Server: the fix's own regression test ── +// Before this pass, this exact scenario was IMPOSSIBLE through Adapt(): +// message_to_request() always produced read_size_or_segment_num == 0, and +// rcp/mock.hpp's own dispatch_uart() reads that field directly +// (`uart_.handle_request(req.evt_op, req.op, payload, +// req.read_size_or_segment_num, ...)`, rcp/mock.hpp) -- so a UART_READ +// relay::Message could only ever drain 0 bytes, no matter how much data +// was actually buffered. This is this file's clearest possible proof the +// fix is real, not cosmetic. + +TEST_CASE("adapt: UART_READ via Adapt() drains exactly rcp.read_size bytes end-to-end", + "[adapt][regression][read_size]") { + auto srv = make_configured_mock_server(); + REQUIRE_FALSE(srv->uart().rx_fill({0x11, 0x22, 0x33, 0x44, 0x55})); + + auto caller = rcp::Adapt(mock_request_fn(srv)); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kUartByteBusId); + req.meta["rcp.op"] = "read"; + req.meta["rcp.read_size"] = "3"; + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE_FALSE(ec); + REQUIRE(resp.payload == std::vector{0x11, 0x22, 0x33}); + REQUIRE(srv->uart().rx_available() == 2); // 2 bytes left undrained +} + +TEST_CASE("adapt: UART_READ via Adapt() with no rcp.read_size meta drains nothing " + "(the pre-fix, read_size-always-0 behavior, still reachable on purpose)", + "[adapt][regression][read_size]") { + auto srv = make_configured_mock_server(); + REQUIRE_FALSE(srv->uart().rx_fill({0xAA, 0xBB})); + + auto caller = rcp::Adapt(mock_request_fn(srv)); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kUartByteBusId); + req.meta["rcp.op"] = "read"; + // no rcp.read_size meta key at all + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE_FALSE(ec); + REQUIRE(resp.payload.empty()); + REQUIRE(srv->uart().rx_available() == 2); // untouched +} + +// ── evt_op end-to-end: GPIO write-semantics and SPI channel select ──────── +// These translate c-RCP's test_gpio_write_request_maps_payload_and_evt_meta +// and test_spi_transfer_request_maps_channel_meta_and_payload +// (tests/test_adapt.c) into equivalence-of-outcome checks: cpp-RCP's single +// generic "rcp.evt_op" meta key stands in for c-RCP's per-endpoint-type +// "rcp.gpio.evt"/"rcp.spi.channel" keys, since evt[2:0] is the exact same +// ACF wire field in both cases (rcp/spi.hpp: "selected via the ACF +// byte_message_info header's evt[2:0] field directly as a channel number"). + +TEST_CASE("adapt: GPIO write via Adapt() applies evt_op as WriteSemantics::Or", + "[adapt][regression][evt_op]") { + auto srv = make_configured_mock_server(); + auto caller = rcp::Adapt(mock_request_fn(srv)); + auto ctx = relay::Context::with_timeout(1s); + + // GpioState's own input-pin write mask (rcp/gpio.hpp's apply_gpio_write: + // "(out & state.directions) | (state.values & ~state.directions)") means + // a plain value write only takes effect on pins already configured as + // outputs — evt_op==7 (WriteSemantics::Reconfigure) sets `directions` + // itself. Two Adapt()-routed writes, both selected purely by evt_op, + // exercise the mapping end-to-end. + relay::Message configure; + configure.id = rcp::endpoint_id_to_relay_id(rcp::mock::kGpioByteBusId); + configure.meta["rcp.op"] = "write"; + configure.meta["rcp.evt_op"] = "7"; // WriteSemantics::Reconfigure -> sets directions + configure.payload = rcp::gpio::encode_gpio_payload(0x0F); + auto [configure_resp, configure_ec] = caller->call(ctx, configure); + REQUIRE_FALSE(configure_ec); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kGpioByteBusId); + req.meta["rcp.op"] = "write"; + req.meta["rcp.evt_op"] = "1"; // WriteSemantics::Or + req.payload = rcp::gpio::encode_gpio_payload(0x0F); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE_FALSE(ec); + + rcp::gpio::PinMask value = 0; + REQUIRE_FALSE(rcp::gpio::decode_gpio_payload(resp.payload.data(), resp.payload.size(), value)); + REQUIRE(value == 0x0F); // 0 | 0x0F, now that all 4 pins are outputs +} + +TEST_CASE("adapt: SPI transfer via Adapt() selects the channel via evt_op", + "[adapt][regression][evt_op]") { + auto srv = make_configured_mock_server(); + srv->set_spi_poci(3, {0x77, 0x88}); + + auto caller = rcp::Adapt(mock_request_fn(srv)); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kSpiByteBusId); + req.meta["rcp.op"] = "read"; + req.meta["rcp.evt_op"] = "3"; // channel 3 + req.payload = {0x00, 0x00}; + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE_FALSE(ec); + REQUIRE(resp.payload == std::vector{0x77, 0x88}); +} + +// ── payload passthrough is opaque ────────────────────────────────────────── +// This header's own `payload` is raw endpoint-specific bytes, untouched and +// unreinterpreted for any op (rcp/adapt.hpp's own header comment) -- unlike +// c-RCP's adapt.c, which decomposes some endpoint types' addressing fields +// (MDIO clause/prtad/devad/regad, CAN frame_format/arbitration_id) into +// separate meta keys because it does full wire encoding itself. In +// cpp-RCP's split architecture those fields, where a wire codec for them +// exists at all (rcp/mdio.hpp), are packed INTO the payload by that +// codec's own caller, not by this header -- so the equivalent behavioral +// guarantee this header owns is simply "payload survives the round trip +// byte-for-byte, whatever it contains." + +TEST_CASE("adapt: arbitrary opaque payload bytes survive message_to_request unchanged", + "[adapt][message_to_request][payload]") { + relay::Message msg; + msg.id = rcp::endpoint_id_to_relay_id(10); + msg.payload = {0x00, 0x01, 0x02, 0xFF, 0xFE, 0x7F, 0x80}; + + rcp::acf::AcfMessageInfo req; + std::vector payload; + REQUIRE(rcp::message_to_request(msg, req, payload)); + REQUIRE(payload == msg.payload); +} + +TEST_CASE("adapt: arbitrary opaque payload bytes survive response_to_message unchanged", + "[adapt][response_to_message][payload]") { + rcp::acf::AcfMessageInfo resp; + resp.byte_bus_id = 11; + std::vector payload = {0xC0, 0xFF, 0xEE}; + + auto msg = rcp::response_to_message(resp, payload); + REQUIRE(msg.payload == payload); +} + +// ── RcpCallerAdapter: send()/call() propagate the wrapped RequestFn's own +// error, and never invoke a null fn ───────────────────────────────────── + +TEST_CASE("adapt: call() propagates the wrapped RequestFn's own error code", + "[adapt][error_propagation]") { + rcp::RequestFn fn = [](const rcp::Context&, const rcp::acf::AcfMessageInfo&, + const std::vector&, rcp::acf::AcfMessageInfo&, + std::vector&) { + return std::make_error_code(std::errc::timed_out); + }; + auto caller = rcp::Adapt(std::move(fn)); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(1); + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE(ec == std::errc::timed_out); +} + +TEST_CASE("adapt: send() propagates the wrapped RequestFn's own error code", + "[adapt][error_propagation]") { + rcp::RequestFn fn = [](const rcp::Context&, const rcp::acf::AcfMessageInfo&, + const std::vector&, rcp::acf::AcfMessageInfo&, + std::vector&) { + return std::make_error_code(std::errc::connection_reset); + }; + auto caller = rcp::Adapt(std::move(fn)); + + relay::Message msg; + msg.id = rcp::endpoint_id_to_relay_id(1); + + auto ctx = relay::Context::with_timeout(1s); + REQUIRE(caller->send(ctx, msg) == std::errc::connection_reset); +} + +TEST_CASE("adapt: call()/send() report not_connected for a default-constructed (empty) RequestFn", + "[adapt][error_propagation]") { + auto caller = rcp::Adapt(rcp::RequestFn{}); + + relay::Message req; + req.id = rcp::endpoint_id_to_relay_id(1); + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, call_ec] = caller->call(ctx, req); + REQUIRE(call_ec == std::errc::not_connected); + REQUIRE(caller->send(ctx, req) == std::errc::not_connected); +} + +// ── Adapt()/RcpCallerAdapter basic contract (protocol/subscribe/close) ──── +// (test_relay.cpp already covers these against a real mock::Server-backed +// RequestFn; kept here too, minimally, so this file stands alone as +// complete coverage of rcp/adapt.hpp.) + +TEST_CASE("adapt: Adapt() returns a non-null relay::Caller whose protocol() is RCP", + "[adapt][contract]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); + REQUIRE(caller != nullptr); + REQUIRE(caller->protocol() == relay::Protocol::RCP); +} + +TEST_CASE("adapt: subscribe() always reports function_not_supported", "[adapt][contract]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); + auto [ch, ec] = caller->subscribe(); + REQUIRE(ch == nullptr); + REQUIRE(ec == std::errc::function_not_supported); +} + +TEST_CASE("adapt: close() always succeeds and is idempotent", "[adapt][contract]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); + REQUIRE_FALSE(caller->close()); + REQUIRE_FALSE(caller->close()); +}