From fe6d16ec4a388819ac6dff04dfb7a06c5659d8bf Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:59:23 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.12.0=20=E2=80=94=20Test=20&=20Simula?= =?UTF-8?q?tion=20Harness=20Rebuild=20(=C2=A7Phase=2014=20milestone=2056)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds rcp/mock.hpp and rcp/sim.hpp against the new TC18 stream/endpoint/register-map model, per the Satellite Package Disposition table's entries for these two files and ROADMAP.md milestone 56. mock::Server is an in-process RC Server simulator holding a real lifecycle::ServerLifecycle (v2.1.0), a real regmap::RegisterMap plus regmap::Ep0 (including EP0's whole-map read and root-client-gated whole-map write), and one instance each of gpio::GpioEndpoint and spi::SpiEndpoint (v2.3.0) as its representative endpoint set — GPIO and SPI were sequenced first in Phase 13 specifically for having the simplest fully-built request/response shapes. Server::dispatch is the single request/response entry point, decoding the standard request kind's evt[2:0]/op fields and gating GPIO/SPI operational traffic on the lifecycle being RCP_CONFIGURED. The old in-process Controller/Registry pair (Zone/Command-shaped) is preserved unchanged under rcp/legacy_mock.hpp, purely so the still-untouched old-model dependents that build against it keep working until each is rebound at its own later milestone: rcp/capi_impl.hpp, rcp/cli.hpp, and rcp/config.hpp (all v2.16.0 per the disposition table), plus every test file using it as a generic in-process test double for its own not-yet-rebuilt package. This is the same file-split precedent rcp/wire.hpp's rcp/legacy_wire.hpp established at v2.0.0 — REQ-CTRL-*/REQ-REG-*/REQ-RESP-*/REQ-STAT-*/ REQ-ERR-011 and their tests/test_mock.cpp coverage move to tests/test_legacy_mock.cpp unchanged. sim::Simulator wraps mock::Server with the latency/jitter modeling concept and Fault/Recover scenario controls carried forward from the discarded sim::Controller, re-targeted at the new request/response shapes, and wires watchdog-miss detection through rcp::watchdog::StreamWatchdog/Manager (v2.10.0) instead of the retired client-driven CommandType::Watchdog kick model. Nothing else depended on the old sim::Controller API, so no legacy shim was needed for sim.hpp. Neither new type spawns a background thread of its own, matching the "primitives, not a scheduler" convention every Phase 14 header has followed since v2.9.0/v2.10.0. New/rewritten coverage lives in tests/test_mock.cpp (REQ-MOCK-001..010, new) and tests/test_sim.cpp (REQ-SIM-001..007, entirely rewritten), traced in .fusa-reqs.json; the pre-replacement mock coverage lives on unchanged as tests/test_legacy_mock.cpp. Bumps kVersion/project version to 2.12.0. Updates ROADMAP.md with a Done (v2.12.0) summary for milestone 56. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 116 +++++-- CMakeLists.txt | 2 +- README.md | 15 +- ROADMAP.md | 69 ++++ include/rcp/adapt.hpp | 9 +- include/rcp/capi_impl.hpp | 16 +- include/rcp/cli.hpp | 13 +- include/rcp/config.hpp | 11 +- include/rcp/legacy_mock.hpp | 276 ++++++++++++++++ include/rcp/mock.hpp | 458 ++++++++++++++------------ include/rcp/sim.hpp | 297 +++++++---------- include/rcp/version.hpp | 2 +- tests/CMakeLists.txt | 4 + tests/bench_mock.cpp | 14 +- tests/command_latency_test.cpp | 4 +- tests/test_admin.cpp | 16 +- tests/test_config.cpp | 6 +- tests/test_faultinject.cpp | 6 +- tests/test_federation.cpp | 20 +- tests/test_firmware.cpp | 16 +- tests/test_legacy_mock.cpp | 467 ++++++++++++++++++++++++++ tests/test_loan.cpp | 6 +- tests/test_mock.cpp | 586 +++++++++------------------------ tests/test_observe.cpp | 16 +- tests/test_prioqueue.cpp | 6 +- tests/test_proxy.cpp | 20 +- tests/test_record.cpp | 18 +- tests/test_redundancy.cpp | 28 +- tests/test_relay.cpp | 16 +- tests/test_sim.cpp | 191 ++++++----- tests/test_tsn.cpp | 6 +- tests/test_zonegroup.cpp | 10 +- 32 files changed, 1695 insertions(+), 1045 deletions(-) create mode 100644 include/rcp/legacy_mock.hpp create mode 100644 tests/test_legacy_mock.cpp diff --git a/.fusa-reqs.json b/.fusa-reqs.json index bd9768e..4b21ee8 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -352,6 +352,86 @@ "level": "ASIL-A", "asil": "ASIL-A" }, + { + "id": "REQ-MOCK-001", + "title": "Server starts HW_UNCONFIGURED with a representative two-endpoint register map", + "text": "rcp::mock::Server shall construct with rcp::lifecycle::ServerLifecycle in state HwUnconfigured and an rcp::regmap::RegisterMap whose endpoint_count, generic_configs, functional_configs, and ep_id_mapping describe exactly two endpoints: GPIO at endpoint id/byte_bus_id 1 and SPI at endpoint id/byte_bus_id 2.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-002", + "title": "advance_to_rcp_configured drives the lifecycle straight to RCP_CONFIGURED", + "text": "rcp::mock::Server::advance_to_rcp_configured shall advance the server's ServerLifecycle from HwUnconfigured through HwConfigured to RcpConfigured and return a falsy std::error_code, since Server supplies no PlausibilityCheck of its own.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-003", + "title": "EP0 read answers the register map's magic number for any client", + "text": "rcp::mock::Server::dispatch, for a read request addressed to regmap::kEp0, shall return a ReadResponse whose payload is the RegisterMap::magic field encoded big-endian, for any client identity, per Ep0::check_read_access's unrestricted-read rule.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-004", + "title": "EP0 write via dispatch is always rejected", + "text": "rcp::mock::Server::dispatch, for a write request addressed to regmap::kEp0, shall return regmap::RegMapErrc::request_rejected and an ErrorResponse, since whole-map writes are only reachable through Ep0::write_whole_map directly, not through dispatch.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-005", + "title": "write_whole_map requires the root client", + "text": "rcp::mock::Server::ep0()'s write_whole_map shall return regmap::RegMapErrc::unauthorized_access for any client that is not the current root client, and shall apply the replacement RegisterMap when called by the root client.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-006", + "title": "Operational GPIO/SPI requests are rejected before RCP_CONFIGURED", + "text": "rcp::mock::Server::dispatch, for a request addressed to the GPIO or SPI endpoint while the server's lifecycle state is not RcpConfigured, shall return regmap::RegMapErrc::request_rejected and an ErrorResponse without touching either endpoint's live state.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-007", + "title": "GPIO write applies evt[2:0] semantics and read reflects the new state", + "text": "rcp::mock::Server::dispatch, for a write request addressed to the GPIO endpoint while RcpConfigured, shall decode the request's evt[2:0] field as an endpoint::WriteSemantics, apply it to the endpoint's live pin-value bitmask via gpio::GpioEndpoint::handle_write, and a subsequent read request shall observe the resulting value.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-008", + "title": "GPIO write with evt_ack set produces an Acknowledge response", + "text": "rcp::mock::Server::dispatch, for a GPIO write request with AcfMessageInfo::evt_ack set, shall build its response via acf::ResponseKind::Acknowledge instead of WriteResponse.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-009", + "title": "SPI transfer answers with the scripted POCI-in bytes for the addressed channel", + "text": "rcp::mock::Server::dispatch, for a request addressed to the SPI endpoint while RcpConfigured, shall decode the request's evt[2:0] field as a channel selector, drive spi::SpiEndpoint::transfer with the request payload as PICO-out data and whatever bytes set_spi_poci last scripted for that channel as POCI-in data, and answer with those POCI-in bytes as a ReadResponse.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-MOCK-010", + "title": "dispatch to an unmapped byte_bus_id reports invalid_parameter", + "text": "rcp::mock::Server::dispatch, for a request whose byte_bus_id addresses neither EP0, GPIO, nor SPI, shall return regmap::RegMapErrc::invalid_parameter and an ErrorResponse.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, { "id": "REQ-CTRL-001", "title": "Controller::send returns StatusOK with no handler", @@ -1250,64 +1330,56 @@ }, { "id": "REQ-SIM-001", - "title": "send() returns OK by default", - "text": "sim::Controller::send() shall return ResponseStatus::OK unless a fault has been injected.", + "title": "dispatch forwards to the wrapped mock::Server by default", + "text": "sim::Simulator::dispatch shall, absent an injected fault, forward its request unchanged to the wrapped mock::Server::dispatch and return its result.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-002", - "title": "zone() returns configured zone", - "text": "sim::Controller::zone() shall return the Zone value from the Config struct.", + "title": "fault() causes dispatch to fail without touching the server", + "text": "sim::Simulator::fault(ec) shall cause every subsequent dispatch() call to return ec and an ErrorResponse without calling mock::Server::dispatch, until recover() is called.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-003", - "title": "Constant latency model", - "text": "When LatencyModel::Constant is configured, send() shall sleep for exactly base_latency before returning.", + "title": "recover() restores normal dispatch", + "text": "sim::Simulator::recover() shall clear any injected fault and restore dispatch() to forwarding requests to the wrapped mock::Server.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-004", - "title": "Jitter latency model", - "text": "When LatencyModel::Jitter is configured, send() shall sleep for a duration uniformly distributed in [base_latency, base_latency + jitter].", + "title": "Constant latency model always reports base_latency", + "text": "sim::Simulator::simulated_latency_ms, when Config::latency_model is LatencyModel::Constant, shall always return Config::base_latency.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-005", - "title": "fault() causes send to fail", - "text": "fault(ec) shall cause all subsequent send() calls to return ec instead of delegating to the handler.", + "title": "Jitter latency model reports a delay within [base_latency, base_latency+jitter]", + "text": "sim::Simulator::simulated_latency_ms, when Config::latency_model is LatencyModel::Jitter, shall return a value uniformly distributed in [base_latency, base_latency + jitter].", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-006", - "title": "recover() restores normal operation", - "text": "recover() shall clear any injected fault and restore send() to OK responses.", + "title": "dispatch kicks a registered stream's watchdog on every accepted request", + "text": "sim::Simulator::dispatch shall call watchdog::Manager::on_request_received for stream_key when that stream is registered (via register_stream), resetting its watchdog per rcp/watchdog.hpp's reset-by-any-inbound-request rule.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-SIM-007", - "title": "Status published on interval", - "text": "If Config::status_interval > 0, the simulator shall publish a Status on that cadence to all subscribers.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-SIM-008", - "title": "close() stops background threads", - "text": "close() shall terminate the status and watchdog background threads without hanging.", + "title": "dispatch on an unregistered stream does not touch the watchdog manager", + "text": "sim::Simulator::dispatch, for a stream_key never passed to register_stream, shall not call watchdog::Manager::on_request_received and shall leave that stream unregistered.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b9fa96..4a3e5d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.11.0 + VERSION 2.12.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/README.md b/README.md index 7e165fb..18e9782 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ reference. | `` | Core interfaces: `Controller`, `Registry`, `Command`, `Response`, `Status`, `Zone`, `Context`, `StatusChannel` | | `` | `Adapt()` — the RELAY §10.3 entry point, wraps a `Controller` as a `relay::Caller`; `ToMessage`/`FromMessage` conversions | | `` | `relay::` namespace types (§18.2): `Protocol`, `Message`, `Errc` sentinels, `Channel`, `Context`, `Node`, `Caller` | -| `` | In-process mock controller and registry — zero I/O, default for unit tests | +| `` | In-process TC18 RC Server simulator (lifecycle + register map + GPIO/SPI) — zero I/O, default for unit tests (ROADMAP.md v2.12.0) | +| `` | Pre-replacement in-process `Controller`/`Registry`, kept only for the old-model dependents not yet rebound to the new request model (`capi_impl.hpp`/`cli.hpp`/`config.hpp`, v2.16.0) | | `` | RELAY-conformant CLI (§11/§12): `version`/`capabilities`/`status`/`send`; `cli/main.cpp` is a thin wrapper around it | | `` | Binary version string — single source of truth for the CLI | | `` / `` | C ABI / FFI surface for RTOS/bare-metal targets (Zephyr/FreeRTOS) | @@ -79,7 +80,7 @@ reference. | `` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing (ROADMAP.md v2.0.0) | | `` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format (ROADMAP.md v2.0.0) | | `` | Pre-replacement 16-byte frame codec, kept only for ``'s internal use until it is rebuilt at v2.13.0 | -| `` | Timing-realistic zone controller simulator for SiL/HIL testing | +| `` | Timing-realistic RC Server simulator for SiL/HIL testing — wraps `` with latency/jitter and Fault/Recover controls, watchdog wired via `` (ROADMAP.md v2.12.0) | ## Build @@ -93,13 +94,19 @@ ctest --test-dir build --output-on-failure ## Quick start +The example below uses ``'s pre-replacement +`Controller`/`Registry` pair, since `rcp/rcp.hpp`'s `Zone`/`Command`/ +`Response` model is itself still pre-replacement (see ROADMAP.md's +Satellite Package Disposition table). For a TC18-shaped in-process server, +see ``'s `mock::Server` instead. + ```cpp #include -#include +#include #include int main() { - auto reg = rcp::mock::new_registry(); + auto reg = rcp::legacy_mock::new_registry(); std::shared_ptr ctrl; reg->lookup(rcp::Zone::FrontLeft, ctrl); diff --git a/ROADMAP.md b/ROADMAP.md index d2f5a3d..6f625da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -936,6 +936,75 @@ pattern in `rcp/regmap.hpp`/`rcp/request.hpp`/`rcp/e2e.hpp`/ - Depends on: everything in Phase 13 through v2.6.0 at minimum, to have a representative register map and safe-state model to simulate +**Done (v2.12.0):** `rcp/mock.hpp` is replaced in full, per the Satellite +Package Disposition table's entry for this file — the prior in-process +`Controller`/`Registry` pair built on rcp.hpp's Zone/Command model is +discarded, not adapted, since zone-addressed request/response has no +analog once addressing moves to server+endpoint identifiers. `mock::Server` +holds a real `lifecycle::ServerLifecycle` (starting `HW_UNCONFIGURED`, per +v2.1.0), a real `regmap::RegisterMap` plus `regmap::Ep0` (including EP0's +unrestricted whole-map read and root-client-gated whole-map write, also +v2.1.0), and one instance each of `gpio::GpioEndpoint` and +`spi::SpiEndpoint` (v2.3.0) as its representative endpoint set — GPIO and +SPI were the natural choice, being the two endpoint types the roadmap +itself sequenced first specifically for having the simplest fully-built +request/response shapes (see milestone 47's own note). `Server::dispatch` +is the single request/response entry point: it decodes the mandatory +standard request kind's `evt[2:0]`/`op` fields (`rcp/acf.hpp`, v2.0.0), +answers EP0 reads with the register map's magic number (the one field a +`rcp/discovery.hpp`-shaped read needs by default — whole-map wire +serialization itself remains out of scope, per `regmap.hpp`'s own header +comment), rejects EP0 writes (reachable only through `ep0().write_whole_map` +directly), and gates GPIO/SPI operational traffic on the lifecycle being +`RCP_CONFIGURED` — this mock's own choice, not a rule reused from +`regmap::Ep0::check_write_access`'s config-block locking, which models a +different thing (whether an endpoint's *configuration* may still change, +not whether the endpoint may be *operated*). + +The old `Controller`/`Registry` content is preserved unchanged, under +`rcp/legacy_mock.hpp`, purely so the still-untouched old-model dependents +that build against it keep working until each is rebound at its own later +milestone — `rcp/capi_impl.hpp`, `rcp/cli.hpp`, and `rcp/config.hpp` (all +v2.16.0 per the disposition table), plus every test file that used the old +mock as a generic in-process test double for its own not-yet-rebuilt +package (`firmware`/`prioqueue`/`proxy`/`record`/`redundancy`/`tsn`/ +`zonegroup`/`federation`/`observe`/`faultinject`/`loan`/`admin`, most +themselves DEPRECATE/ADAPT candidates for later milestones). This is the +same file-split precedent `rcp/wire.hpp`'s `rcp/legacy_wire.hpp` established +at v2.0.0 — REQ-CTRL-*/REQ-REG-*/REQ-RESP-*/REQ-STAT-*/REQ-ERR-011 and +their `tests/test_mock.cpp` coverage move to `tests/test_legacy_mock.cpp` +unchanged, matching `REQ-UDP-*`'s equivalent move at that milestone. + +`rcp/sim.hpp` is likewise replaced in full — the prior `sim::Controller`, +which implemented the full old `rcp::Controller` interface plus a +client-driven `CommandType::Watchdog` kick model, is discarded outright +(nothing else in this tree depended on it beyond its own test, so no +legacy shim was needed here, unlike `mock.hpp`'s split). `sim::Simulator` +wraps one `mock::Server` with the latency/jitter modeling concept carried +forward unchanged from the pre-replacement design (`LatencyModel:: +Constant`/`Jitter`, `simulated_latency_ms()`) and the same Fault/Recover +scenario-testing concept, re-targeted at this module's +`std::error_code`/`AcfMessageInfo` response shape. Watchdog-miss detection +is wired through `rcp::watchdog::StreamWatchdog`/`Manager` (v2.10.0) +exactly as the roadmap called for: `Simulator::dispatch` kicks a +registered stream's watchdog via `Manager::on_request_received` on every +accepted request, and `poll_watchdog` forwards to `Manager::poll` for the +caller to drive on its own schedule. Per the same "primitives, not a +scheduler" convention every Phase 14 header has followed since v2.9.0/ +v2.10.0, `Simulator` spawns no background thread of its own — the old +`sim::Controller`'s status/watchdog polling threads are not carried +forward; `simulated_latency_ms()` reports a delay for the caller to apply +however it sees fit rather than sleeping on the caller's behalf. + +New/rewritten coverage lives in `tests/test_mock.cpp` (`REQ-MOCK-001..010`, +new) and `tests/test_sim.cpp` (`REQ-SIM-001..007`, entirely rewritten under +the same file/prefix identity as the discarded pre-replacement coverage), +traced in `.fusa-reqs.json`; the pre-replacement mock coverage lives on, +unchanged, as `tests/test_legacy_mock.cpp`. Full bit-for-bit conformance +against other TC18 implementations is not claimed, same as the equivalent +disclaimers in `rcp/regmap.hpp`, `rcp/lifecycle.hpp`, `rcp/gpio.hpp`, and +`rcp/spi.hpp`. + --- ### Phase 15 — Transport & Ecosystem Bridge Migration --- diff --git a/include/rcp/adapt.hpp b/include/rcp/adapt.hpp index 463783c..def20a6 100644 --- a/include/rcp/adapt.hpp +++ b/include/rcp/adapt.hpp @@ -11,9 +11,16 @@ // underlying protocol with a single constructor change. // // Usage: -// auto ctrl = std::make_shared(rcp::Zone::FrontLeft); +// auto ctrl = std::make_shared(rcp::Zone::FrontLeft); // auto caller = rcp::Adapt(ctrl); // relay::Caller* // auto [resp, ec] = caller->call(ctx, msg); +// +// The example above uses rcp/legacy_mock.hpp's Controller (renamed from +// rcp/mock.hpp at ROADMAP.md milestone 56, v2.12.0 — see its own header +// comment) since this header itself is still built on rcp.hpp's +// pre-replacement Zone/Command/Controller model; per the Satellite Package +// Disposition table's entry for `adapt.hpp`, rebinding Adapt()/ToMessage/ +// FromMessage to the new request/response shapes is v2.16.0 scope. #pragma once #include diff --git a/include/rcp/capi_impl.hpp b/include/rcp/capi_impl.hpp index d59c966..ead0871 100644 --- a/include/rcp/capi_impl.hpp +++ b/include/rcp/capi_impl.hpp @@ -4,6 +4,14 @@ // declared in capi.h. Bridges flat C handles to the C++ mock controller // and in-process registry. // +// Still built on rcp.hpp's pre-replacement Zone/Command/Controller/Registry +// model and rcp/legacy_mock.hpp's Controller/Registry backing it (renamed +// from rcp/mock.hpp at ROADMAP.md milestone 56, v2.12.0, when that name +// became the new RC Server simulator — see rcp/legacy_mock.hpp's header +// comment). Per the Satellite Package Disposition table's entry for +// `capi.h`/`capi_impl.hpp`, this file's own REPLACE against the new +// request model is v2.16.0 scope, not this milestone's. +// // fusa:req REQ-CAPI-001 // fusa:req REQ-CAPI-002 // fusa:req REQ-CAPI-003 @@ -15,18 +23,18 @@ #pragma once #include "capi.h" -#include "mock.hpp" +#include "legacy_mock.hpp" #include "rcp.hpp" // ── Backing storage layout ──────────────────────────────────────────────────── // We use placement-new into caller-supplied buffers to avoid heap allocation. struct rcp_registry_s { - rcp::mock::Registry impl; + rcp::legacy_mock::Registry impl; }; struct rcp_ctrl_s { - std::shared_ptr impl; + std::shared_ptr impl; }; static_assert(sizeof(rcp_registry_s) <= 512, "rcp_registry_s too large"); @@ -50,7 +58,7 @@ inline rcp_err_t rcp_registry_close(rcp_registry_h reg) { inline rcp_err_t rcp_ctrl_init(rcp_zone_t zone, void* buf, size_t buf_len, rcp_ctrl_h* out) { if (buf_len < sizeof(rcp_ctrl_s)) return RCP_ERR_NOMEM; auto* h = new (buf) rcp_ctrl_s{}; - h->impl = std::make_shared(static_cast(zone)); + h->impl = std::make_shared(static_cast(zone)); *out = h; return RCP_OK; } diff --git a/include/rcp/cli.hpp b/include/rcp/cli.hpp index 865066f..7ec0d94 100644 --- a/include/rcp/cli.hpp +++ b/include/rcp/cli.hpp @@ -14,11 +14,18 @@ // without spawning a subprocess; cli/main.cpp is a thin wrapper around it. // // Binary name: `cpp-rcp` (§13.2). Build with -DRELAY_BUILD_CLI=ON. +// +// Still built on rcp.hpp's pre-replacement Zone/Command/Controller model +// and rcp/legacy_mock.hpp's Registry backing send_one/send_stream's +// in-process demo transport (renamed from rcp/mock.hpp at ROADMAP.md +// milestone 56, v2.12.0 — see rcp/legacy_mock.hpp's header comment). Per +// the Satellite Package Disposition table's entry for `cli.hpp`, rebinding +// `send`'s addressing to the new server/endpoint model is v2.16.0 scope. #pragma once #include #include -#include +#include #include #include @@ -393,7 +400,7 @@ inline int send_one(const std::vector& args, bool json, cmd.type = type; cmd.payload = payload; - mock::Registry reg; + legacy_mock::Registry reg; std::shared_ptr ctrl; if (reg.lookup(zone, ctrl)) { err << "send: zone '" << zone_flag << "' not found\n"; @@ -420,7 +427,7 @@ inline int send_one(const std::vector& args, bool json, // skipped (a single bad message must not tear down a crossbar route); only the // final count is written to `out`. Exit code is always kOk on clean EOF. inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { - mock::Registry reg; + legacy_mock::Registry reg; std::string line; std::size_t sent = 0; while (std::getline(in, line)) { diff --git a/include/rcp/config.hpp b/include/rcp/config.hpp index 9d05628..d3fba91 100644 --- a/include/rcp/config.hpp +++ b/include/rcp/config.hpp @@ -17,9 +17,16 @@ // { "zone": "FrontLeft", "priority": "Normal" }, // { "zone": "FrontRight", "priority": "Normal" } // ]} +// +// Still built on rcp.hpp's pre-replacement Zone/Controller/Registry model +// and rcp/legacy_mock.hpp's Controller (renamed from rcp/mock.hpp at +// ROADMAP.md milestone 56, v2.12.0 — see rcp/legacy_mock.hpp's header +// comment). Per the Satellite Package Disposition table's entry for +// `config.hpp`, rebuilding this loader around a server/endpoint manifest +// schema is a later milestone, not this one. #pragma once -#include "mock.hpp" +#include "legacy_mock.hpp" #include "rcp.hpp" #include @@ -112,7 +119,7 @@ inline Manifest parse_json(const std::string& json) { inline std::error_code load(const std::string& json, rcp::Registry& reg) { Manifest m = parse_json(json); for (auto& entry : m.zones) { - auto ctrl = std::make_shared(entry.zone); + auto ctrl = std::make_shared(entry.zone); auto ec = reg.register_ctrl(ctrl); if (ec) return ec; } diff --git a/include/rcp/legacy_mock.hpp b/include/rcp/legacy_mock.hpp new file mode 100644 index 0000000..8845bda --- /dev/null +++ b/include/rcp/legacy_mock.hpp @@ -0,0 +1,276 @@ +// fusa:req REQ-CTRL-001 +// fusa:req REQ-CTRL-002 +// fusa:req REQ-CTRL-003 +// fusa:req REQ-CTRL-004 +// fusa:req REQ-CTRL-005 +// fusa:req REQ-CTRL-006 +// fusa:req REQ-CTRL-007 +// fusa:req REQ-CTRL-008 +// fusa:req REQ-CTRL-009 +// fusa:req REQ-CTRL-010 +// fusa:req REQ-CTRL-011 +// fusa:req REQ-CTRL-012 +// fusa:req REQ-CTRL-013 +// fusa:req REQ-CTRL-014 +// fusa:req REQ-CTRL-015 +// fusa:req REQ-CTRL-016 +// fusa:req REQ-CTRL-017 +// fusa:req REQ-CTRL-018 +// fusa:req REQ-CTRL-019 +// fusa:req REQ-CTRL-020 +// fusa:req REQ-CTRL-021 +// fusa:req REQ-CTRL-022 +// fusa:req REQ-CTRL-023 +// fusa:req REQ-CTRL-024 +// fusa:req REQ-CTRL-025 +// fusa:req REQ-CTRL-026 +// fusa:req REQ-CTRL-027 +// fusa:req REQ-REG-001 +// fusa:req REQ-REG-002 +// fusa:req REQ-REG-003 +// fusa:req REQ-REG-004 +// fusa:req REQ-REG-005 +// fusa:req REQ-REG-006 +// fusa:req REQ-REG-007 +// fusa:req REQ-REG-008 +// fusa:req REQ-REG-009 +// fusa:req REQ-REG-010 +// fusa:req REQ-REG-011 +// fusa:req REQ-REG-012 +// fusa:req REQ-REG-013 +// fusa:req REQ-RESP-001 +// fusa:req REQ-RESP-002 +// fusa:req REQ-STAT-001 +// fusa:req REQ-STAT-002 +// fusa:req REQ-STAT-003 +// fusa:req REQ-STAT-004 +// fusa:req REQ-STAT-005 +// fusa:req REQ-ERR-011 + +// In-process controller/registry for the pre-replacement Zone/Command/ +// Response/Status model (rcp.hpp), preserved unchanged under this name so +// the still-untouched old-model dependents that build against it keep +// working until each of them is rebound at its own later milestone. +// +// ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild (v2.12.0)": +// this is the pre-replacement content that used to live in rcp/mock.hpp — +// moved here, verbatim in behavior and REQ-CTRL-*/REQ-REG-*/REQ-RESP-*/ +// REQ-STAT-*/REQ-ERR-011 identity, only renamed (mock -> legacy_mock, same +// as rcp/wire.hpp's old 16-byte codec became rcp/legacy_wire.hpp at +// v2.0.0) so rcp/mock.hpp itself could become the new RC Server simulator +// this milestone calls for without breaking the roughly three dozen other +// headers/tests still built on rcp.hpp's Zone/Command/Controller/Registry +// model and not yet rebound to the new one: rcp/capi_impl.hpp, +// rcp/cli.hpp, and rcp/config.hpp (all scheduled for v2.16.0 per the +// Satellite Package Disposition table's `capi_impl.hpp`/`cli.hpp`/ +// `config.hpp` entries), plus every test file that used the old +// mock::Controller/Registry purely as a generic in-process test double for +// its own not-yet-rebuilt package (firmware/prioqueue/proxy/record/ +// redundancy/tsn/zonegroup/federation/observe/faultinject/loan/admin — +// most themselves DEPRECATE/ADAPT candidates for later milestones per that +// same table). +// +// Do not build new functionality on top of this file — it is a +// deliberately temporary bridge, not a second mock implementation going +// forward. See rcp/mock.hpp for the new RC Server simulator. +// +// All operations execute synchronously in memory — no I/O, minimal threading +// (one watcher thread per subscription). The mock is safe for concurrent use. +#pragma once + +#include "rcp.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace legacy_mock { + +// Handler is a user-supplied function that produces a Response for a Command. +// If nullptr, the controller returns StatusOK with empty payload. +using Handler = std::function; + +// Controller is a mock zone controller that handles commands in-process. +class Controller final : public rcp::Controller { +public: + explicit Controller(Zone zone, Handler handler = nullptr) + : zone_(zone), handler_(std::move(handler)) {} + + ~Controller() override { (void)close(); } + + Zone zone() const noexcept override { return zone_; } + + std::error_code send(const rcp::Context& ctx, + const Command& cmd, + Response& out) override { + if (closed_.load(std::memory_order_acquire)) + return ErrClosed; + if (ctx.done()) + return ErrTimeout; + if (cmd.zone != zone_) + return ErrZoneMismatch; + + // Copy payload before passing to handler (REQ-CTRL-026). + Command safe = cmd; + if (!cmd.payload.empty()) { + safe.payload = cmd.payload; + } + + if (handler_) { + out = handler_(safe); + } else { + out = Response{cmd.id, zone_, ResponseStatus::OK, {}}; + } + return {}; + } + + std::error_code subscribe(const rcp::Context& ctx, + std::shared_ptr& out) override { + auto ch = std::make_shared(16); + { + // Check closed_ and append under the same critical section close() + // uses, so a subscribe() racing a close() cannot observe "open" and + // then add to subs_ after close() has already cleared it (REQ-CTRL-008; + // RELAY spec §6.2/6.3/6.6 require a deterministic ErrClosed here, not + // a transiently-valid channel). + std::lock_guard lk(mu_); + if (closed_.load(std::memory_order_acquire)) + return ErrClosed; + subs_.push_back(ch); + } + + // Watcher thread: remove subscription when ctx expires or controller closes. + std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { + // Poll until ctx done or channel closed. + while (!ctx.done()) { + if (closed_.load(std::memory_order_acquire)) break; + auto ch_ptr = weak_ch.lock(); + if (!ch_ptr || ch_ptr->is_closed()) break; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + auto ch_ptr = weak_ch.lock(); + if (!ch_ptr) return; + { + std::lock_guard lk(mu_); + auto it = std::find(subs_.begin(), subs_.end(), ch_ptr); + if (it != subs_.end()) subs_.erase(it); + } + ch_ptr->close(); + }).detach(); + + out = std::move(ch); + return {}; + } + + // Publish pushes a Status update to all active subscribers. + void publish(const std::vector& payload) { + uint32_t seq = ++seq_; + // Copy payload so caller mutation cannot affect delivered Status (REQ-CTRL-027). + std::vector p = payload; + + Status st{zone_, seq, !closed_.load(std::memory_order_acquire), std::move(p)}; + std::lock_guard lk(mu_); + for (auto& s : subs_) { + s->push(st); + } + } + + std::error_code close() override { + // The closed_ exchange lives under mu_ too, so it is properly serialized + // against subscribe()'s check-and-append critical section above. + std::lock_guard lk(mu_); + bool was_open = !closed_.exchange(true, std::memory_order_acq_rel); + if (!was_open) return {}; + for (auto& s : subs_) s->close(); + subs_.clear(); + return {}; + } + +private: + Zone zone_; + Handler handler_; + std::atomic closed_{false}; + std::atomic seq_{0}; + std::mutex mu_; + std::vector> subs_; +}; + +// Registry is an in-process RCP registry backed by mock controllers. +class Registry final : public rcp::Registry { +public: + Registry() { + for (auto z : {Zone::FrontLeft, Zone::FrontRight, + Zone::RearLeft, Zone::RearRight, + Zone::Central}) { + ctrls_[z] = std::make_shared(z); + } + } + + ~Registry() override { (void)close(); } + + std::error_code register_ctrl(std::shared_ptr ctrl) override { + auto* mc = dynamic_cast(ctrl.get()); + if (!mc) return std::make_error_code(std::errc::invalid_argument); + + std::unique_lock lk(mu_); + if (closed_) return ErrClosed; + if (ctrls_.count(mc->zone())) return ErrAlreadyExists; + ctrls_[mc->zone()] = std::shared_ptr(ctrl, mc); + return {}; + } + + std::error_code deregister(Zone z) override { + std::unique_lock lk(mu_); + auto it = ctrls_.find(z); + if (it == ctrls_.end()) return ErrNotFound; + auto ctrl = it->second; + ctrls_.erase(it); + lk.unlock(); + return ctrl->close(); + } + + std::error_code lookup(Zone z, std::shared_ptr& out) override { + std::shared_lock lk(mu_); + if (closed_) return ErrClosed; + auto it = ctrls_.find(z); + if (it == ctrls_.end()) return ErrNotFound; + out = it->second; + return {}; + } + + std::vector> controllers() override { + std::shared_lock lk(mu_); + std::vector> out; + out.reserve(ctrls_.size()); + for (auto& [z, c] : ctrls_) out.push_back(c); + return out; + } + + std::error_code close() override { + std::unique_lock lk(mu_); + if (closed_) return {}; + closed_ = true; + auto local = std::move(ctrls_); + lk.unlock(); + for (auto& [z, c] : local) (void)c->close(); + return {}; + } + +private: + mutable std::shared_mutex mu_; + std::map> ctrls_; + bool closed_ = false; +}; + +// Convenience factory — analogous to mock.NewRegistry() in Go. +inline std::unique_ptr new_registry() { + return std::make_unique(); +} + +} // namespace legacy_mock +} // namespace rcp diff --git a/include/rcp/mock.hpp b/include/rcp/mock.hpp index 78c2931..3d9084d 100644 --- a/include/rcp/mock.hpp +++ b/include/rcp/mock.hpp @@ -1,251 +1,285 @@ -// fusa:req REQ-CTRL-001 -// fusa:req REQ-CTRL-002 -// fusa:req REQ-CTRL-003 -// fusa:req REQ-CTRL-004 -// fusa:req REQ-CTRL-005 -// fusa:req REQ-CTRL-006 -// fusa:req REQ-CTRL-007 -// fusa:req REQ-CTRL-008 -// fusa:req REQ-CTRL-009 -// fusa:req REQ-CTRL-010 -// fusa:req REQ-CTRL-011 -// fusa:req REQ-CTRL-012 -// fusa:req REQ-CTRL-013 -// fusa:req REQ-CTRL-014 -// fusa:req REQ-CTRL-015 -// fusa:req REQ-CTRL-016 -// fusa:req REQ-CTRL-017 -// fusa:req REQ-CTRL-018 -// fusa:req REQ-CTRL-019 -// fusa:req REQ-CTRL-020 -// fusa:req REQ-CTRL-021 -// fusa:req REQ-CTRL-022 -// fusa:req REQ-CTRL-023 -// fusa:req REQ-CTRL-024 -// fusa:req REQ-CTRL-025 -// fusa:req REQ-CTRL-026 -// fusa:req REQ-CTRL-027 -// fusa:req REQ-REG-001 -// fusa:req REQ-REG-002 -// fusa:req REQ-REG-003 -// fusa:req REQ-REG-004 -// fusa:req REQ-REG-005 -// fusa:req REQ-REG-006 -// fusa:req REQ-REG-007 -// fusa:req REQ-REG-008 -// fusa:req REQ-REG-009 -// fusa:req REQ-REG-010 -// fusa:req REQ-REG-011 -// fusa:req REQ-REG-012 -// fusa:req REQ-REG-013 -// fusa:req REQ-RESP-001 -// fusa:req REQ-RESP-002 -// fusa:req REQ-STAT-001 -// fusa:req REQ-STAT-002 -// fusa:req REQ-STAT-003 -// fusa:req REQ-STAT-004 -// fusa:req REQ-STAT-005 -// fusa:req REQ-ERR-011 - -// Package mock provides an in-process RCP controller and registry for unit tests. +// fusa:req REQ-MOCK-001 +// fusa:req REQ-MOCK-002 +// fusa:req REQ-MOCK-003 +// fusa:req REQ-MOCK-004 +// fusa:req REQ-MOCK-005 +// fusa:req REQ-MOCK-006 +// fusa:req REQ-MOCK-007 +// fusa:req REQ-MOCK-008 +// fusa:req REQ-MOCK-009 +// fusa:req REQ-MOCK-010 + +// In-process RC Server simulator — a small, representative OPEN Alliance +// TC18 Remote Control Protocol Specification v0.5.1_RC server built +// entirely on the new stream/endpoint/register-map model, for unit tests +// and other in-process callers that want something more realistic than a +// hand-rolled stub to dispatch requests against. +// +// ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild (v2.12.0)", +// opening Phase 14's final pair of milestones: this header REPLACES this +// file's pre-replacement content in full, per the Satellite Package +// Disposition table's entry for `mock.hpp` — the prior in-process +// `Controller`/`Registry` pair built on rcp.hpp's Zone/Command model is +// discarded, not adapted, since it has no analog once addressing moves +// from zone name to server+endpoint identifier. That old content is +// preserved unchanged, under rcp/legacy_mock.hpp, purely so the +// still-untouched old-model dependents that build against it +// (rcp/capi_impl.hpp, rcp/cli.hpp, rcp/config.hpp, all v2.16.0; and every +// test file using it as a generic in-process test double for its own +// not-yet-rebuilt package) keep working until each is rebound at its own +// later milestone — see rcp/legacy_mock.hpp's own header comment and +// rcp/legacy_wire.hpp's equivalent precedent at v2.0.0. +// +// mock::Server holds a real rcp::lifecycle::ServerLifecycle (v2.1.0), a +// real rcp::regmap::RegisterMap plus rcp::regmap::Ep0 (v2.1.0, including +// EP0 whole-map-read and root-client write semantics), and one instance +// each of the two simplest fully-built endpoint types — rcp::gpio:: +// GpioEndpoint and rcp::spi::SpiEndpoint (both v2.3.0) — as its +// representative endpoint set. dispatch() below is the single +// request/response entry point a test drives, decoding the standard +// request kind's evt[2:0]/op fields (rcp/acf.hpp, v2.0.0) the same way a +// real request-dispatch loop would. Conditional request kinds (v2.5.0), +// E2E CRC safe points (v2.6.0), and watchdog wiring (v2.10.0) are +// deliberately layered on top by rcp/sim.hpp rather than folded in here — +// this header's own scope is the server model and a representative +// endpoint set, matching the roadmap's own split between the two files. // -// All operations execute synchronously in memory — no I/O, minimal threading -// (one watcher thread per subscription). The mock is safe for concurrent use. +// Whole-register-map wire serialization remains out of scope, per +// rcp/regmap.hpp's own header comment — EP0's dispatch()-level read +// answers with just the register map's magic number (the one field a +// rcp/discovery.hpp-shaped read actually needs by default), and EP0 +// writes are only reachable through ep0()'s direct write_whole_map() call, +// not through dispatch(), same "data model, not wire codec" scope split +// regmap.hpp itself documents. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The concrete endpoint +// numbering (GPIO at endpoint id / byte_bus_id 1, SPI at 2), access-policy +// choice for operational requests (gated on lifecycle state only, not +// per-endpoint ownership — see dispatch()'s own comment), and EP0 partial- +// read encoding chosen in this file are this implementation's own, purely +// for the purposes of being a usable in-process simulator — full +// bit-for-bit conformance against other TC18 implementations is not +// claimed, same as the equivalent disclaimers in rcp/regmap.hpp, +// rcp/lifecycle.hpp, rcp/gpio.hpp, and rcp/spi.hpp. #pragma once -#include "rcp.hpp" +#include +#include +#include +#include +#include +#include +#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include #include namespace rcp { namespace mock { -// Handler is a user-supplied function that produces a Response for a Command. -// If nullptr, the controller returns StatusOK with empty payload. -using Handler = std::function; +// ── Representative endpoint set ────────────────────────────────────────────── +// Endpoint ids double as byte_bus_id values in this simulator — a +// deliberately simple 1:1 choice this mock is free to make since it owns +// both the register map and the ep_id_mapping table it populates itself +// (extraction §3.9's client-guaranteed table-order caveat, which +// rcp/regmap.hpp flags explicitly, does not apply here: there is no +// client populating this table, only this constructor). + +using regmap::EndpointId; -// Controller is a mock zone controller that handles commands in-process. -class Controller final : public rcp::Controller { +constexpr EndpointId kGpioEndpointId = 1; +constexpr EndpointId kSpiEndpointId = 2; +constexpr avtp::ByteBusId kGpioByteBusId = static_cast(kGpioEndpointId); +constexpr avtp::ByteBusId kSpiByteBusId = static_cast(kSpiEndpointId); + +// A discovery-shaped EP0 read only ever answers with the register map's +// magic number below — see this header's own scope note above. +constexpr size_t kEp0PartialReadLen = sizeof(uint32_t); + +// ── Server ──────────────────────────────────────────────────────────────────── +// A single simulated RC Server instance. Not copyable — regmap::Ep0 holds +// references into this object's own RegisterMap/ServerLifecycle members, +// so a Server is meant to be owned by reference or a smart pointer, the +// same restriction Ep0 itself already carries. +class Server final { public: - explicit Controller(Zone zone, Handler handler = nullptr) - : zone_(zone), handler_(std::move(handler)) {} - - ~Controller() override { (void)close(); } - - Zone zone() const noexcept override { return zone_; } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - if (closed_.load(std::memory_order_acquire)) - return ErrClosed; - if (ctx.done()) - return ErrTimeout; - if (cmd.zone != zone_) - return ErrZoneMismatch; - - // Copy payload before passing to handler (REQ-CTRL-026). - Command safe = cmd; - if (!cmd.payload.empty()) { - safe.payload = cmd.payload; - } + Server() + : lifecycle_(), + regs_(make_initial_register_map()), + ep0_(regs_, lifecycle_) {} - if (handler_) { - out = handler_(safe); - } else { - out = Response{cmd.id, zone_, ResponseStatus::OK, {}}; - } - return {}; - } + Server(const Server&) = delete; + Server& operator=(const Server&) = delete; - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - auto ch = std::make_shared(16); - { - // Check closed_ and append under the same critical section close() - // uses, so a subscribe() racing a close() cannot observe "open" and - // then add to subs_ after close() has already cleared it (REQ-CTRL-008; - // RELAY spec §6.2/6.3/6.6 require a deterministic ErrClosed here, not - // a transiently-valid channel). - std::lock_guard lk(mu_); - if (closed_.load(std::memory_order_acquire)) - return ErrClosed; - subs_.push_back(ch); - } + lifecycle::ServerLifecycle& lifecycle() noexcept { return lifecycle_; } + const lifecycle::ServerLifecycle& lifecycle() const noexcept { return lifecycle_; } - // Watcher thread: remove subscription when ctx expires or controller closes. - std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { - // Poll until ctx done or channel closed. - while (!ctx.done()) { - if (closed_.load(std::memory_order_acquire)) break; - auto ch_ptr = weak_ch.lock(); - if (!ch_ptr || ch_ptr->is_closed()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - auto ch_ptr = weak_ch.lock(); - if (!ch_ptr) return; - { - std::lock_guard lk(mu_); - auto it = std::find(subs_.begin(), subs_.end(), ch_ptr); - if (it != subs_.end()) subs_.erase(it); - } - ch_ptr->close(); - }).detach(); - - out = std::move(ch); - return {}; - } + regmap::RegisterMap& registers() noexcept { return regs_; } + const regmap::RegisterMap& registers() const noexcept { return regs_; } - // Publish pushes a Status update to all active subscribers. - void publish(const std::vector& payload) { - uint32_t seq = ++seq_; - // Copy payload so caller mutation cannot affect delivered Status (REQ-CTRL-027). - std::vector p = payload; + regmap::Ep0& ep0() noexcept { return ep0_; } - Status st{zone_, seq, !closed_.load(std::memory_order_acquire), std::move(p)}; - std::lock_guard lk(mu_); - for (auto& s : subs_) { - s->push(st); - } + gpio::GpioEndpoint& gpio() noexcept { return gpio_; } + spi::SpiEndpoint& spi() noexcept { return spi_; } + + // set_spi_poci scripts the bytes a subsequent dispatch()/transfer() + // call on `channel` reads back as POCI-in data. A real SPI peripheral's + // response is whatever hardware is attached to that channel; this + // simulator lets a test script it directly instead of modeling actual + // hardware. + void set_spi_poci(uint8_t channel, std::vector data) { + if (channel < spi::kMaxChannels) spi_poci_[channel] = std::move(data); } - std::error_code close() override { - // The closed_ exchange lives under mu_ too, so it is properly serialized - // against subscribe()'s check-and-append critical section above. - std::lock_guard lk(mu_); - bool was_open = !closed_.exchange(true, std::memory_order_acq_rel); - if (!was_open) return {}; - for (auto& s : subs_) s->close(); - subs_.clear(); - return {}; + // advance_to_rcp_configured is a convenience for tests/simulators that + // don't care about exercising ServerLifecycle's intermediate + // plausibility-check gating themselves and just want a fully live + // server to dispatch operational requests against — this Server + // supplies no PlausibilityCheck of its own (both transitions always + // succeed), so this always succeeds too. + std::error_code advance_to_rcp_configured() noexcept { + auto ec = lifecycle_.advance(lifecycle::ServerState::HwConfigured); + if (ec) return ec; + return lifecycle_.advance(lifecycle::ServerState::RcpConfigured); } -private: - Zone zone_; - Handler handler_; - std::atomic closed_{false}; - std::atomic seq_{0}; - std::mutex mu_; - std::vector> subs_; -}; + // dispatch is this simulator's single request/response entry point, + // modeling the standard request kind (rcp::acf::RequestKind::Standard, + // v2.0.0) only — conditional kinds are rcp/request.hpp's concern, not + // this simulator's. `client` is the opaque per-connection index + // rcp/regmap.hpp's Ep0 already uses for root-client/ownership checks + // (see its own header comment). Every non-EP0 endpoint's operational + // (non-EP0) requests are gated purely on lifecycle state here — this + // mock's own choice of "operational traffic answers once fully live, + // rejected before that" rather than reusing Ep0::check_write_access's + // config-block locking (which models something different: whether an + // endpoint's *configuration* may still change, not whether the + // endpoint may be *operated*). Returns the same std::error_code the + // failing step below produced; out_resp is always populated (Error/Ack/ + // Read/WriteResponse, per rcp::acf::make_response) even on failure, so + // a caller can always encode *something* back to a client. + std::error_code dispatch(size_t client, const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + out_resp_payload.clear(); + if (req.byte_bus_id == regmap::kEp0) return dispatch_ep0(client, req, out_resp, out_resp_payload); + if (req.byte_bus_id == kGpioByteBusId) return dispatch_gpio(req, req_payload, out_resp, out_resp_payload); + if (req.byte_bus_id == kSpiByteBusId) return dispatch_spi(req, req_payload, out_resp, out_resp_payload); -// Registry is an in-process RCP registry backed by mock controllers. -class Registry final : public rcp::Registry { -public: - Registry() { - for (auto z : {Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central}) { - ctrls_[z] = std::make_shared(z); - } + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return make_error_code(regmap::RegMapErrc::invalid_parameter); } - ~Registry() override { (void)close(); } - - std::error_code register_ctrl(std::shared_ptr ctrl) override { - auto* mc = dynamic_cast(ctrl.get()); - if (!mc) return std::make_error_code(std::errc::invalid_argument); +private: + static regmap::RegisterMap make_initial_register_map() { + regmap::RegisterMap regs; + regs.endpoint_count = 2; + regs.generic_configs.resize(2); + regs.functional_configs.resize(2); + regs.ep_id_mapping = { + {kGpioEndpointId, kGpioByteBusId}, + {kSpiEndpointId, kSpiByteBusId}, + }; + return regs; + } - std::unique_lock lk(mu_); - if (closed_) return ErrClosed; - if (ctrls_.count(mc->zone())) return ErrAlreadyExists; - ctrls_[mc->zone()] = std::shared_ptr(ctrl, mc); + std::error_code dispatch_ep0(size_t /*client*/, const acf::AcfMessageInfo& req, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + if (req.op) { + // Whole-map writes go through ep0().write_whole_map() at the + // object level, not through this byte-oriented path — see this + // header's own scope note. + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return make_error_code(regmap::RegMapErrc::request_rejected); + } + auto ec = ep0_.check_read_access(regmap::kEp0); + if (ec) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return ec; + } + out_resp_payload.resize(kEp0PartialReadLen); + avtp::detail::put_u32(out_resp_payload.data(), regs_.magic); + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); return {}; } - std::error_code deregister(Zone z) override { - std::unique_lock lk(mu_); - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - auto ctrl = it->second; - ctrls_.erase(it); - lk.unlock(); - return ctrl->close(); + bool operational_requests_allowed() const noexcept { + return lifecycle_.state() == lifecycle::ServerState::RcpConfigured; } - std::error_code lookup(Zone z, std::shared_ptr& out) override { - std::shared_lock lk(mu_); - if (closed_) return ErrClosed; - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - out = it->second; + std::error_code dispatch_gpio(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + if (!operational_requests_allowed()) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return make_error_code(regmap::RegMapErrc::request_rejected); + } + if (!req.op) { + out_resp_payload = gpio::encode_gpio_payload(gpio_.read()); + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + return {}; + } + gpio::PinMask operand = 0; + auto ec = gpio::decode_gpio_payload(payload.data(), payload.size(), operand); + if (ec) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return ec; + } + gpio::PinMask new_value = 0; + ec = gpio_.handle_write(endpoint::write_semantics_of(req.evt_op), operand, new_value); + if (ec) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return ec; + } + out_resp_payload = gpio::encode_gpio_payload(new_value); + out_resp = acf::make_response(req, req.evt_ack ? acf::ResponseKind::Acknowledge + : acf::ResponseKind::WriteResponse); return {}; } - std::vector> controllers() override { - std::shared_lock lk(mu_); - std::vector> out; - out.reserve(ctrls_.size()); - for (auto& [z, c] : ctrls_) out.push_back(c); - return out; - } - - std::error_code close() override { - std::unique_lock lk(mu_); - if (closed_) return {}; - closed_ = true; - auto local = std::move(ctrls_); - lk.unlock(); - for (auto& [z, c] : local) (void)c->close(); + // SPI is inherently full-duplex (extraction §5.4) — this dispatch path + // deliberately does not branch on req.op the way GPIO's does: every SPI + // request, read or write, drives one spi::SpiEndpoint::transfer() using + // `payload` as the PICO-out bytes (empty for a pure read) and answers + // with whatever POCI-in bytes set_spi_poci() last scripted for that + // channel. + std::error_code dispatch_spi(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + if (!operational_requests_allowed()) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return make_error_code(regmap::RegMapErrc::request_rejected); + } + uint8_t channel = 0; + auto ec = spi::channel_of(req.evt_op, channel); + if (ec) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return ec; + } + ec = spi_.transfer(channel, payload, spi_poci_[channel]); + if (ec) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + return ec; + } + out_resp_payload = spi_.last_received(channel); + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); return {}; } -private: - mutable std::shared_mutex mu_; - std::map> ctrls_; - bool closed_ = false; + lifecycle::ServerLifecycle lifecycle_; + regmap::RegisterMap regs_; + regmap::Ep0 ep0_; + gpio::GpioEndpoint gpio_; + spi::SpiEndpoint spi_; + std::array, spi::kMaxChannels> spi_poci_{}; }; -// Convenience factory — analogous to mock.NewRegistry() in Go. -inline std::unique_ptr new_registry() { - return std::make_unique(); -} - } // namespace mock } // namespace rcp diff --git a/include/rcp/sim.hpp b/include/rcp/sim.hpp index bb67ad6..76e3836 100644 --- a/include/rcp/sim.hpp +++ b/include/rcp/sim.hpp @@ -5,30 +5,59 @@ // fusa:req REQ-SIM-005 // fusa:req REQ-SIM-006 // fusa:req REQ-SIM-007 -// fusa:req REQ-SIM-008 -// Timing-realistic zone controller simulator for SiL/HIL testing. +// Timing-realistic RC Server simulator for SiL/HIL testing — wraps +// rcp/mock.hpp's in-process Server with configurable latency/jitter and +// Fault/Recover scenario controls, and wires watchdog-miss detection +// through rcp/watchdog.hpp's per-stream driver (v2.10.0). // -// sim::Controller implements the full rcp::Controller interface and adds -// Fault/Recover controls for deterministic scenario testing. Configurable -// latency (constant or jitter), periodic Status publishing, and watchdog-miss -// detection enable validation of safety mechanisms from v0.11.0–v0.16.0. +// ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild (v2.12.0)": +// this header REPLACES this file's pre-replacement content in full, per +// the Satellite Package Disposition table's entry for `sim.hpp` — the +// prior sim::Controller, which implemented the full old rcp::Controller +// interface plus a client-driven CommandType::Watchdog kick model, is +// discarded, not adapted (that request/response shape and that watchdog +// model both have no analog in the target protocol; the target protocol's +// actual per-stream watchdog reset rule already exists as +// rcp/watchdog.hpp's StreamWatchdog/Manager, v2.10.0). Nothing else in +// this tree depended on the old sim::Controller API (only this file's own +// test did), so no legacy shim is needed here — unlike rcp/mock.hpp's +// legacy_mock.hpp split, sim.hpp's old content is not preserved anywhere. +// +// This header, like every Phase 14 primitive header since v2.9.0/v2.10.0, +// supplies no clock or background thread of its own — the pre-replacement +// sim::Controller's status/watchdog polling threads are not carried +// forward. Every timed effect (simulated latency, watchdog kicks/polls) is +// driven by an explicit now_ms a caller supplies, the same "primitive, not +// scheduler" convention rcp/watchdog.hpp's Manager, rcp/deadline.hpp's +// Monitor, and rcp/e2e.hpp's RxWatchdog already established. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The concrete latency/ +// jitter model and Fault/Recover shape chosen in this file are this +// implementation's own, carried forward unchanged in concept from the +// pre-replacement sim::Controller — full bit-for-bit conformance against +// other TC18 implementations is not claimed, same as the equivalent +// disclaimer in rcp/mock.hpp. #pragma once -#include "rcp.hpp" +#include +#include -#include #include -#include -#include +#include #include -#include +#include #include namespace rcp { namespace sim { // ── LatencyModel ───────────────────────────────────────────────────────────── +// Carried forward unchanged in concept from the pre-replacement +// sim::Controller: Constant always reports the same delay; Jitter adds a +// uniformly distributed extra delay on top of it. enum class LatencyModel : uint8_t { Constant = 0, @@ -38,187 +67,97 @@ enum class LatencyModel : uint8_t { // ── Config ──────────────────────────────────────────────────────────────────── struct Config { - Zone zone; - std::chrono::milliseconds base_latency {2}; - std::chrono::milliseconds jitter {1}; - std::chrono::milliseconds status_interval {10}; // 0 = disabled - std::chrono::milliseconds watchdog_timeout{50}; // 0 = disabled - LatencyModel latency_model {LatencyModel::Jitter}; + std::chrono::milliseconds base_latency{2}; + std::chrono::milliseconds jitter{1}; + LatencyModel latency_model{LatencyModel::Jitter}; }; -inline Config default_config(Zone z) { - Config cfg; - cfg.zone = z; - return cfg; -} - -// ── Controller ──────────────────────────────────────────────────────────────── - -class Controller final : public rcp::Controller { +// ── Simulator ───────────────────────────────────────────────────────────────── +// Wraps one mock::Server with scenario-testing controls. Not copyable — +// mock::Server itself is not copyable (see its own header comment). +class Simulator final { public: - using Handler = std::function; - - explicit Controller(Config cfg, Handler handler = nullptr) - : cfg_(cfg), handler_(std::move(handler)), - rng_(std::random_device{}()) { - if (cfg_.status_interval.count() > 0) - status_thread_ = std::thread([this]{ status_loop(); }); - if (cfg_.watchdog_timeout.count() > 0) - wd_thread_ = std::thread([this]{ watchdog_loop(); }); - } - - ~Controller() override { - closed_.store(true, std::memory_order_release); - if (status_thread_.joinable()) status_thread_.join(); - if (wd_thread_.joinable()) wd_thread_.join(); - (void)close(); - } - - Zone zone() const noexcept override { return cfg_.zone; } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - if (closed_.load(std::memory_order_acquire)) return ErrClosed; - if (ctx.done()) return ErrTimeout; - if (cmd.zone != cfg_.zone) return ErrZoneMismatch; - - { - std::lock_guard lk(mu_); - if (fault_err_) return fault_err_; - } - - // Simulate response latency. - auto delay = cfg_.base_latency; - if (cfg_.latency_model == LatencyModel::Jitter && cfg_.jitter.count() > 0) { - std::uniform_int_distribution dist(0, cfg_.jitter.count()); - std::lock_guard lk(mu_); - delay += std::chrono::milliseconds(dist(rng_)); - } - if (delay.count() > 0) - std::this_thread::sleep_for(delay); - - if (ctx.done()) return ErrTimeout; - - // Reset watchdog on CmdWatchdog. - if (cmd.type == CommandType::Watchdog) { - wd_last_kick_.store( - std::chrono::steady_clock::now().time_since_epoch().count(), - std::memory_order_release); - } - - if (handler_) { - std::lock_guard lk(mu_); - out = handler_(cmd); - } else { - out = Response{cmd.id, cfg_.zone, ResponseStatus::OK, {}}; - } - return {}; + explicit Simulator(Config cfg = {}) : cfg_(cfg), rng_(std::random_device{}()) {} + + Simulator(const Simulator&) = delete; + Simulator& operator=(const Simulator&) = delete; + + mock::Server& server() noexcept { return server_; } + watchdog::Manager& watchdog() noexcept { return watchdog_mgr_; } + + // register_stream begins watchdog tracking for a request stream — + // forwards to rcp::watchdog::Manager::register_stream (v2.10.0). Call + // once per stream a scenario wants watchdog-miss detection for; a + // stream dispatch() is never called for simply never has its watchdog + // kicked or polled. + void register_stream(uint64_t stream_key) { watchdog_mgr_.register_stream(stream_key); } + + // fault injects `err` on every subsequent dispatch() call until + // recover() is called — the same Fault/Recover scenario-testing + // concept the pre-replacement sim::Controller offered, re-targeted at + // this module's std::error_code/AcfMessageInfo response shape. + void fault(std::error_code err) { fault_err_ = err; } + + // recover clears any injected fault, restoring normal dispatch. + void recover() { fault_err_ = {}; } + + bool faulted() const noexcept { return static_cast(fault_err_); } + + // simulated_latency_ms draws this call's simulated response delay per + // cfg_'s LatencyModel — Constant always returns base_latency; Jitter + // adds a uniformly distributed extra delay in [0, jitter]. This class + // never sleeps on the caller's behalf (see this header's own + // "primitive, not scheduler" note) — a caller that wants to actually + // simulate the delay (e.g. a real SiL/HIL harness, or a test advancing + // its own now_ms clock) does so itself with the value returned here. + std::chrono::milliseconds simulated_latency_ms() { + if (cfg_.latency_model == LatencyModel::Constant || cfg_.jitter.count() <= 0) + return cfg_.base_latency; + std::uniform_int_distribution dist(0, cfg_.jitter.count()); + return cfg_.base_latency + std::chrono::milliseconds(dist(rng_)); } - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - if (closed_.load(std::memory_order_acquire)) return ErrClosed; - auto ch = std::make_shared(16); - { - std::lock_guard lk(mu_); - subs_.push_back(ch); + // dispatch is mock::Server::dispatch's timing-realistic wrapper: an + // injected fault (see fault() above) short-circuits before the server + // is touched at all; otherwise `stream_key`'s watchdog is kicked (if + // registered — see register_stream) per rcp/watchdog.hpp's "reset by + // any inbound request" rule, and the request is forwarded to + // mock::Server::dispatch unchanged. `now_ms` drives the watchdog kick + // only — simulated latency is reported via simulated_latency_ms(), not + // applied as a real sleep here, per this class's own scope note. + std::error_code dispatch(uint64_t stream_key, size_t client, + const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, + uint64_t now_ms) { + if (fault_err_) { + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + out_resp_payload.clear(); + return fault_err_; } - std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { - while (!ctx.done() && !closed_.load()) { - auto c = weak_ch.lock(); - if (!c || c->is_closed()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - auto c = weak_ch.lock(); - if (!c) return; - std::lock_guard lk(mu_); - auto it = std::find(subs_.begin(), subs_.end(), c); - if (it != subs_.end()) subs_.erase(it); - c->close(); - }).detach(); - out = std::move(ch); - return {}; - } - - std::error_code close() override { - if (!closed_.exchange(true, std::memory_order_acq_rel)) { - std::lock_guard lk(mu_); - for (auto& s : subs_) s->close(); - subs_.clear(); + if (watchdog_mgr_.is_registered(stream_key)) { + (void)watchdog_mgr_.on_request_received(stream_key, now_ms); } - return {}; - } - - // fault injects err on all subsequent send() calls until recover() is called. - void fault(std::error_code err) { - std::lock_guard lk(mu_); - fault_err_ = err; - } - - // recover clears any injected fault. - void recover() { - std::lock_guard lk(mu_); - fault_err_ = {}; + return server_.dispatch(client, req, req_payload, out_resp, out_resp_payload); } - bool watchdog_missed() const noexcept { - return wd_miss_.load(std::memory_order_acquire); - } - - // publish sends a Status to all subscribers (called by test harness). - void publish(const std::vector& payload) { - uint32_t seq = ++seq_; - Status st{cfg_.zone, seq, !closed_.load(), payload}; - std::lock_guard lk(mu_); - for (auto& s : subs_) s->push(st); + // poll_watchdog is a thin forwarder to rcp::watchdog::Manager::poll for + // one registered stream — see rcp/watchdog.hpp. Deciding when to call + // this (i.e. running a timer loop) is the embedding + // application's/test's job, same as watchdog::Manager itself. + std::error_code poll_watchdog(uint64_t stream_key, const regmap::RequestStreamConfig& cfg, + request::RequestLedger& ledger, uint64_t now_ms) { + return watchdog_mgr_.poll(stream_key, cfg, ledger, now_ms); } private: - Config cfg_; - Handler handler_; - std::mt19937 rng_; - std::atomic closed_{false}; - std::atomic seq_{0}; - std::atomic wd_miss_{false}; - std::atomic wd_last_kick_{0}; - - std::mutex mu_; - std::error_code fault_err_; - std::vector> subs_; - std::thread status_thread_; - std::thread wd_thread_; - - void status_loop() { - while (!closed_.load(std::memory_order_acquire)) { - publish({}); - std::this_thread::sleep_for(cfg_.status_interval); - } - } - - void watchdog_loop() { - while (!closed_.load(std::memory_order_acquire)) { - std::this_thread::sleep_for(cfg_.watchdog_timeout); - auto last = wd_last_kick_.load(std::memory_order_acquire); - if (last == 0) { - wd_miss_.store(true, std::memory_order_release); - continue; - } - auto last_tp = std::chrono::steady_clock::time_point( - std::chrono::steady_clock::duration(last)); - auto elapsed = std::chrono::steady_clock::now() - last_tp; - if (elapsed >= cfg_.watchdog_timeout) { - wd_miss_.store(true, std::memory_order_release); - } else { - wd_miss_.store(false, std::memory_order_release); - } - } - } + Config cfg_; + std::mt19937 rng_; + std::error_code fault_err_; + mock::Server server_; + watchdog::Manager watchdog_mgr_; }; -inline std::unique_ptr new_controller(Config cfg, Controller::Handler h = nullptr) { - return std::make_unique(cfg, std::move(h)); -} - } // namespace sim } // namespace rcp diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 0bfe074..89d8f77 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.11.0"; +constexpr std::string_view kVersion = "2.12.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bbe4f48..17f1f2f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,10 @@ add_executable(test_mock test_mock.cpp) target_link_libraries(test_mock PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_mock COMMAND test_mock) +add_executable(test_legacy_mock test_legacy_mock.cpp) +target_link_libraries(test_legacy_mock PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_legacy_mock COMMAND test_legacy_mock) + add_executable(test_avtp test_avtp.cpp) target_link_libraries(test_avtp PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_avtp COMMAND test_avtp) diff --git a/tests/bench_mock.cpp b/tests/bench_mock.cpp index adf5f09..24e4fd5 100644 --- a/tests/bench_mock.cpp +++ b/tests/bench_mock.cpp @@ -5,7 +5,7 @@ // Or directly: ./tests/bench_mock [!benchmark] #include #include -#include +#include #include #include @@ -14,8 +14,8 @@ using namespace rcp; // ── Helpers ─────────────────────────────────────────────────────────────────── -static std::shared_ptr make_ctrl(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +static std::shared_ptr make_ctrl(Zone z = Zone::FrontLeft) { + return std::make_shared(z); } // ── Sanity test (always runs in CTest) ─────────────────────────────────────── @@ -37,7 +37,7 @@ TEST_CASE("Benchmark: Send_RoundTrip", "[!benchmark]") { Response resp; auto ctx = Context::background(); - BENCHMARK("mock::Controller::send round-trip") { + BENCHMARK("legacy_mock::Controller::send round-trip") { auto ec = ctrl->send(ctx, cmd, resp); return ec; }; @@ -51,7 +51,7 @@ TEST_CASE("Benchmark: Send_RoundTrip_WithPayload", "[!benchmark]") { Response resp; auto ctx = Context::background(); - BENCHMARK("mock::Controller::send 64-byte payload") { + BENCHMARK("legacy_mock::Controller::send 64-byte payload") { auto ec = ctrl->send(ctx, cmd, resp); return ec; }; @@ -102,9 +102,9 @@ TEST_CASE("Benchmark: Publish_FanOut", "[!benchmark]") { } TEST_CASE("Benchmark: Registry_Lookup", "[!benchmark]") { - auto reg = mock::new_registry(); + auto reg = legacy_mock::new_registry(); - BENCHMARK("mock::Registry::lookup") { + BENCHMARK("legacy_mock::Registry::lookup") { std::shared_ptr out; auto ec = reg->lookup(Zone::FrontLeft, out); return ec; diff --git a/tests/command_latency_test.cpp b/tests/command_latency_test.cpp index 5fbecd9..bf06fae 100644 --- a/tests/command_latency_test.cpp +++ b/tests/command_latency_test.cpp @@ -8,7 +8,7 @@ // (wide enough to absorb OS/VM scheduler jitter on any shared, virtualized, or // sandboxed host, not only hosts with CI=1 set). #include -#include +#include #include #include @@ -25,7 +25,7 @@ static int64_t ns(steady_clock::duration d) { static double us(int64_t n) { return static_cast(n) / 1000.0; } TEST_CASE("Command latency P99 < 1ms over 30s workload", "[latency][safety]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); Command cmd; cmd.zone = Zone::FrontLeft; diff --git a/tests/test_admin.cpp b/tests/test_admin.cpp index 4b81009..66f06f4 100644 --- a/tests/test_admin.cpp +++ b/tests/test_admin.cpp @@ -9,7 +9,7 @@ #include #include "rcp/admin.hpp" -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include #include @@ -18,18 +18,18 @@ using namespace rcp; TEST_CASE("admin: zones lists registered controllers", "[admin]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); auto zones = srv.zones(); - REQUIRE(zones.size() == 5); // mock::Registry pre-populates all 5 + REQUIRE(zones.size() == 5); // legacy_mock::Registry pre-populates all 5 for (auto& zi : zones) { REQUIRE(zi.registered); } } TEST_CASE("admin: subscribe receives emitted events", "[admin]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); std::vector received; @@ -44,7 +44,7 @@ TEST_CASE("admin: subscribe receives emitted events", "[admin]") { } TEST_CASE("admin: metrics_text contains counter lines", "[admin]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); srv.record_counter("rcp.commands.total", "zone=\"FrontLeft\"", 10.0); @@ -56,7 +56,7 @@ TEST_CASE("admin: metrics_text contains counter lines", "[admin]") { } TEST_CASE("admin: multiple subscribers all receive events", "[admin]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); int count_a = 0, count_b = 0; @@ -70,7 +70,7 @@ TEST_CASE("admin: multiple subscribers all receive events", "[admin]") { } TEST_CASE("admin: event delivers correct type and zone", "[admin][REQ-ADMIN-008]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); admin::Event got{}; @@ -83,7 +83,7 @@ TEST_CASE("admin: event delivers correct type and zone", "[admin][REQ-ADMIN-008] TEST_CASE("admin: concurrent record_counter and emit are thread-safe", "[admin][REQ-ADMIN-004][REQ-ADMIN-005]") { - mock::Registry reg; + legacy_mock::Registry reg; admin::AdminServer srv(reg); std::atomic events{0}; diff --git a/tests/test_config.cpp b/tests/test_config.cpp index a0ffd2c..026bd80 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -7,7 +7,7 @@ #include #include "rcp/config.hpp" -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/proxy.hpp" #include @@ -43,8 +43,8 @@ TEST_CASE("config: load registers controllers", "[config]") { ] })"; - mock::Registry reg; - // Remove pre-registered zones first (mock::Registry pre-populates all 5) + legacy_mock::Registry reg; + // Remove pre-registered zones first (legacy_mock::Registry pre-populates all 5) // We test that load succeeds when zones are not yet registered. // Use a proxy registry which starts empty. proxy::ProxyRegistry preg; diff --git a/tests/test_faultinject.cpp b/tests/test_faultinject.cpp index 2c8aaee..383f334 100644 --- a/tests/test_faultinject.cpp +++ b/tests/test_faultinject.cpp @@ -8,13 +8,13 @@ // fusa:test REQ-FI-008 #include -#include +#include #include using namespace rcp; -static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { + return std::make_shared(z); } // ── No rules ───────────────────────────────────────────────────────────────── diff --git a/tests/test_federation.cpp b/tests/test_federation.cpp index f0e7c7a..20579e2 100644 --- a/tests/test_federation.cpp +++ b/tests/test_federation.cpp @@ -9,17 +9,17 @@ #include #include "rcp/federation.hpp" -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" using namespace rcp; TEST_CASE("federation: local controller preferred over lease", "[federation]") { auto reg = federation::new_registry("hpc-a"); - auto local_ctrl = std::make_shared(Zone::FrontLeft); + auto local_ctrl = std::make_shared(Zone::FrontLeft); REQUIRE_FALSE(reg->register_ctrl(local_ctrl)); // Add a lease for the same zone — local should win - auto remote_ctrl = std::make_shared(Zone::FrontLeft); + auto remote_ctrl = std::make_shared(Zone::FrontLeft); federation::Lease lease; lease.owner = "hpc-b"; lease.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(60); @@ -34,7 +34,7 @@ TEST_CASE("federation: local controller preferred over lease", "[federation]") { TEST_CASE("federation: remote lease used when no local", "[federation][REQ-FED-004]") { auto reg = federation::new_registry("hpc-a"); - auto remote_ctrl = std::make_shared(Zone::RearLeft); + auto remote_ctrl = std::make_shared(Zone::RearLeft); federation::Lease lease; lease.owner = "hpc-b"; lease.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(60); @@ -49,7 +49,7 @@ TEST_CASE("federation: remote lease used when no local", "[federation][REQ-FED-0 TEST_CASE("federation: expired lease returns ErrNotFound", "[federation]") { auto reg = federation::new_registry("hpc-a"); - auto remote_ctrl = std::make_shared(Zone::Central); + auto remote_ctrl = std::make_shared(Zone::Central); federation::Lease lease; lease.owner = "hpc-b"; lease.expires_at = std::chrono::steady_clock::now() - std::chrono::seconds(1); // expired @@ -64,7 +64,7 @@ TEST_CASE("federation: expired lease returns ErrNotFound", "[federation]") { TEST_CASE("federation: revoke_lease removes lease", "[federation]") { auto reg = federation::new_registry("hpc-a"); - auto remote_ctrl = std::make_shared(Zone::FrontRight); + auto remote_ctrl = std::make_shared(Zone::FrontRight); federation::Lease lease; lease.owner = "hpc-b"; lease.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(60); @@ -83,18 +83,18 @@ TEST_CASE("federation: local_id is preserved", "[federation][REQ-FED-005]") { TEST_CASE("federation: register_ctrl rejects duplicate zone", "[federation][REQ-FED-008]") { auto reg = federation::new_registry("hpc-a"); - REQUIRE_FALSE(reg->register_ctrl(std::make_shared(Zone::Central))); - auto ec = reg->register_ctrl(std::make_shared(Zone::Central)); + REQUIRE_FALSE(reg->register_ctrl(std::make_shared(Zone::Central))); + auto ec = reg->register_ctrl(std::make_shared(Zone::Central)); REQUIRE(ec == ErrAlreadyExists); } TEST_CASE("federation: close closes local and remote controllers", "[federation][REQ-FED-006]") { auto reg = federation::new_registry("hpc-a"); - auto local_ctrl = std::make_shared(Zone::FrontLeft); + auto local_ctrl = std::make_shared(Zone::FrontLeft); REQUIRE_FALSE(reg->register_ctrl(local_ctrl)); - auto remote_ctrl = std::make_shared(Zone::RearLeft); + auto remote_ctrl = std::make_shared(Zone::RearLeft); federation::Lease lease; lease.owner = "hpc-b"; lease.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(60); diff --git a/tests/test_firmware.cpp b/tests/test_firmware.cpp index d6d8ba4..fd88f55 100644 --- a/tests/test_firmware.cpp +++ b/tests/test_firmware.cpp @@ -9,7 +9,7 @@ #include #include "rcp/firmware.hpp" -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include #include @@ -17,20 +17,20 @@ using namespace rcp; TEST_CASE("firmware: state machine starts Idle", "[firmware]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); REQUIRE(session.state() == firmware::SessionState::Idle); } TEST_CASE("firmware: initiate transitions to Initiated", "[firmware]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); REQUIRE_FALSE(session.initiate(Context{}, "1.2.3")); REQUIRE(session.state() == firmware::SessionState::Initiated); } TEST_CASE("firmware: double initiate fails", "[firmware]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); REQUIRE_FALSE(session.initiate(Context{}, "1.0")); auto ec = session.initiate(Context{}, "1.0"); @@ -38,7 +38,7 @@ TEST_CASE("firmware: double initiate fails", "[firmware]") { } TEST_CASE("firmware: transfer requires Initiated state", "[firmware]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); std::vector image(8192, 0xAA); auto ec = session.transfer(Context{}, image); @@ -46,7 +46,7 @@ TEST_CASE("firmware: transfer requires Initiated state", "[firmware]") { } TEST_CASE("firmware: full happy path", "[firmware]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); REQUIRE_FALSE(session.initiate(Context{}, "2.0.0")); @@ -69,7 +69,7 @@ TEST_CASE("firmware: full happy path", "[firmware]") { TEST_CASE("firmware: transfer chunks image in chunk_size segments with per-chunk progress", "[firmware][REQ-FW-006][REQ-FW-008]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::Config cfg; cfg.chunk_size = 1024; firmware::FirmwareSession session(ctrl, cfg); @@ -97,7 +97,7 @@ TEST_CASE("firmware: transfer chunks image in chunk_size segments with per-chunk } TEST_CASE("firmware: rollback resets to Idle", "[firmware][REQ-FW-007]") { - auto ctrl = std::make_shared(Zone::FrontLeft); + auto ctrl = std::make_shared(Zone::FrontLeft); firmware::FirmwareSession session(ctrl); REQUIRE_FALSE(session.initiate(Context{}, "1.0")); REQUIRE_FALSE(session.transfer(Context{}, std::vector(1, 0))); diff --git a/tests/test_legacy_mock.cpp b/tests/test_legacy_mock.cpp new file mode 100644 index 0000000..89ff20a --- /dev/null +++ b/tests/test_legacy_mock.cpp @@ -0,0 +1,467 @@ +// fusa:test REQ-CTRL-001 +// fusa:test REQ-CTRL-002 +// fusa:test REQ-CTRL-003 +// fusa:test REQ-CTRL-004 +// fusa:test REQ-CTRL-005 +// fusa:test REQ-CTRL-006 +// fusa:test REQ-CTRL-007 +// fusa:test REQ-CTRL-008 +// fusa:test REQ-CTRL-009 +// fusa:test REQ-CTRL-010 +// fusa:test REQ-CTRL-011 +// fusa:test REQ-CTRL-012 +// fusa:test REQ-CTRL-013 +// fusa:test REQ-CTRL-014 +// fusa:test REQ-CTRL-015 +// fusa:test REQ-CTRL-016 +// fusa:test REQ-CTRL-017 +// fusa:test REQ-CTRL-018 +// fusa:test REQ-CTRL-019 +// fusa:test REQ-CTRL-020 +// fusa:test REQ-CTRL-021 +// fusa:test REQ-CTRL-022 +// fusa:test REQ-CTRL-023 +// fusa:test REQ-CTRL-024 +// fusa:test REQ-CTRL-025 +// fusa:test REQ-CTRL-026 +// fusa:test REQ-CTRL-027 +// fusa:test REQ-REG-001 +// fusa:test REQ-REG-002 +// fusa:test REQ-REG-003 +// fusa:test REQ-REG-004 +// fusa:test REQ-REG-005 +// fusa:test REQ-REG-006 +// fusa:test REQ-REG-007 +// fusa:test REQ-REG-008 +// fusa:test REQ-REG-009 +// fusa:test REQ-REG-010 +// fusa:test REQ-REG-011 +// fusa:test REQ-REG-012 +// fusa:test REQ-REG-013 +// fusa:test REQ-RESP-001 +// fusa:test REQ-RESP-002 +// fusa:test REQ-STAT-001 +// fusa:test REQ-STAT-002 +// fusa:test REQ-STAT-003 +// fusa:test REQ-STAT-004 +// fusa:test REQ-ERR-011 + +// Tests for rcp/legacy_mock.hpp — moved unchanged from tests/test_mock.cpp +// (ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild", v2.12.0) +// when rcp/mock.hpp itself became the new RC Server simulator; only the +// mock:: -> legacy_mock:: namespace rename applies here, same as +// rcp/legacy_wire.hpp's tests/test_legacy_wire.cpp precedent at v2.0.0. See +// tests/test_mock.cpp for the new mock::Server coverage. + +#include +#include + +#include +#include +#include +#include + +using namespace rcp; + +// ── Registry pre-population ─────────────────────────────────────────────────── + +TEST_CASE("NewRegistry pre-populates all five standard zones", "[registry][REQ-REG-001]") { + auto reg = legacy_mock::new_registry(); + for (auto z : {Zone::FrontLeft, Zone::FrontRight, + Zone::RearLeft, Zone::RearRight, + Zone::Central}) { + std::shared_ptr ctrl; + REQUIRE_FALSE(reg->lookup(z, ctrl)); + REQUIRE(ctrl->zone() == z); + } + reg->close(); +} + +TEST_CASE("Registry::register_ctrl returns ErrAlreadyExists for duplicate zone", "[registry][REQ-REG-002]") { + auto reg = legacy_mock::new_registry(); + auto dup = std::make_shared(Zone::FrontLeft); + REQUIRE(reg->register_ctrl(dup) == ErrAlreadyExists); + reg->close(); +} + +TEST_CASE("Registry::deregister removes and closes the controller", "[registry][REQ-REG-003]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); + std::shared_ptr ctrl; + REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); + reg->close(); +} + +TEST_CASE("Registry::lookup returns ErrNotFound for absent zone", "[registry][REQ-REG-004]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); + std::shared_ptr ctrl; + REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); + reg->close(); +} + +TEST_CASE("Registry::close is idempotent", "[registry][REQ-REG-005]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->close()); + REQUIRE_FALSE(reg->close()); +} + +TEST_CASE("Registry::controllers returns all registered controllers", "[registry][REQ-REG-006]") { + auto reg = legacy_mock::new_registry(); + auto ctrls = reg->controllers(); + REQUIRE(ctrls.size() == 5); + reg->close(); +} + +TEST_CASE("Registry::register_ctrl returns ErrClosed after close", "[registry][REQ-REG-007]") { + auto reg = legacy_mock::new_registry(); + reg->close(); + auto extra = std::make_shared(Zone::FrontLeft); + REQUIRE(reg->register_ctrl(extra) == ErrClosed); +} + +TEST_CASE("Registry::deregister returns ErrNotFound for never-registered zone", "[registry][REQ-REG-008]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); + REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); + reg->close(); +} + +TEST_CASE("Registered controller is immediately retrievable via lookup", "[registry][REQ-REG-009]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); + auto nc = std::make_shared(Zone::FrontLeft); + REQUIRE_FALSE(reg->register_ctrl(nc)); + std::shared_ptr got; + REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, got)); + REQUIRE(got.get() == nc.get()); + reg->close(); +} + +TEST_CASE("Registry::close closes all registered controllers", "[registry][REQ-REG-010]") { + auto reg = legacy_mock::new_registry(); + std::shared_ptr ctrl; + REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, ctrl)); + reg->close(); + Response out; + auto ec = ctrl->send(Context::background(), Command{0, Zone::FrontLeft}, out); + REQUIRE(ec == ErrClosed); +} + +TEST_CASE("Registry::lookup on closed registry returns ErrClosed", "[registry][REQ-REG-011][REQ-REG-013]") { + auto reg = legacy_mock::new_registry(); + reg->close(); + std::shared_ptr ctrl; + REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrClosed); +} + +TEST_CASE("Registry::deregister for already-deregistered zone returns ErrNotFound", "[registry][REQ-REG-012]") { + auto reg = legacy_mock::new_registry(); + REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); + REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); + reg->close(); +} + +// ── Controller::send ────────────────────────────────────────────────────────── + +TEST_CASE("Controller::send returns StatusOK with no handler", "[controller][REQ-CTRL-001]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); + REQUIRE(out.status == ResponseStatus::OK); +} + +TEST_CASE("Controller::send dispatches to handler and returns response verbatim", "[controller][REQ-CTRL-002][REQ-CTRL-016]") { + bool called = false; + legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command& cmd) -> Response { + called = true; + return Response{cmd.id, cmd.zone, ResponseStatus::Error, {}}; + }); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{42, Zone::FrontLeft}, out)); + REQUIRE(called); + REQUIRE(out.status == ResponseStatus::Error); + REQUIRE(out.command_id == 42); +} + +TEST_CASE("Controller::send returns ErrClosed after close", "[controller][REQ-CTRL-003]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + ctrl.close(); + Response out; + REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out) == ErrClosed); +} + +TEST_CASE("Controller::send returns ErrTimeout when ctx is already done", "[controller][REQ-CTRL-004][REQ-CTRL-023]") { + bool handler_called = false; + legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command&) -> Response { + handler_called = true; + return {}; + }); + auto past = Context::with_deadline( + std::chrono::steady_clock::now() - std::chrono::seconds(1)); + Response out; + REQUIRE(ctrl.send(past, Command{1, Zone::FrontLeft}, out) == ErrTimeout); + REQUIRE_FALSE(handler_called); +} + +TEST_CASE("Controller::close is idempotent", "[controller][REQ-CTRL-005]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + REQUIRE_FALSE(ctrl.close()); + REQUIRE_FALSE(ctrl.close()); +} + +TEST_CASE("Controller::subscribe channel closes when controller closes", "[controller][REQ-CTRL-007]") { + auto ctrl = std::make_unique(Zone::FrontLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl->subscribe(Context::background(), ch)); + ctrl->close(); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + REQUIRE(ch->is_closed()); +} + +TEST_CASE("Controller::subscribe returns ErrClosed after close", "[controller][REQ-CTRL-008]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + ctrl.close(); + std::shared_ptr ch; + REQUIRE(ctrl.subscribe(Context::background(), ch) == ErrClosed); +} + +TEST_CASE("Controller::subscribe concurrent with close is race-free", "[controller][REQ-CTRL-008]") { + // Regression test: subscribe() and close() used to check `closed_` and + // mutate `subs_` under separate critical sections, so a subscribe() could + // observe "open" and still add its channel to subs_ after close() had + // already run and cleared it — the added channel would only self-heal + // later via its own watcher thread polling `closed_`, not synchronously + // as part of close(). Now both are serialized under the same mutex, so + // whenever a subscribe() call succeeds concurrently with close(), close() + // is guaranteed to see (and synchronously close) that channel itself — + // checkable immediately after both threads join, with no sleep. + for (int iter = 0; iter < 200; ++iter) { + auto ctrl = std::make_unique(Zone::FrontLeft); + + std::vector> channels; + std::mutex channels_mu; + + std::vector subscribers; + for (int i = 0; i < 4; ++i) { + subscribers.emplace_back([&] { + std::shared_ptr ch; + if (!ctrl->subscribe(Context::background(), ch)) { + std::lock_guard lk(channels_mu); + channels.push_back(std::move(ch)); + } + }); + } + std::thread closer([&] { ctrl->close(); }); + + for (auto& t : subscribers) t.join(); + closer.join(); + + // No sleep: with the fix, every channel a successful subscribe() handed + // back is guaranteed closed by the time close() has returned, because + // close() (having won or lost the race under the shared lock) either + // never sees the channel or closes it itself before releasing the lock. + for (auto& ch : channels) { + REQUIRE(ch->is_closed()); + } + } +} + +TEST_CASE("Controller::zone returns declared zone", "[controller][REQ-CTRL-009]") { + legacy_mock::Controller ctrl(Zone::RearRight); + REQUIRE(ctrl.zone() == Zone::RearRight); +} + +TEST_CASE("Status Seq values are strictly increasing", "[controller][REQ-CTRL-010][REQ-STAT-002]") { + legacy_mock::Controller ctrl(Zone::Central); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + ctrl.publish({}); + ctrl.publish({}); + ctrl.publish({}); + uint32_t last = 0; + for (int i = 0; i < 3; ++i) { + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->seq > last); + last = s->seq; + } + ctrl.close(); +} + +TEST_CASE("Subscribe channel is closed when its context expires", "[controller][REQ-CTRL-011]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + auto ctx = Context::with_timeout(std::chrono::milliseconds(20)); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(ctx, ch)); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + REQUIRE(ch->is_closed()); + ctrl.close(); +} + +TEST_CASE("Multiple concurrent subscribers each receive published status", "[controller][REQ-CTRL-006][REQ-CTRL-012]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + constexpr int N = 3; + std::vector> channels(N); + for (auto& ch : channels) REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + ctrl.publish({0x01}); + for (auto& ch : channels) { + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->zone == Zone::FrontLeft); + REQUIRE(s->payload == std::vector{0x01}); + } + ctrl.close(); +} + +TEST_CASE("Controller::send with CmdNoop returns StatusOK", "[controller][REQ-CTRL-013]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Noop}, out)); + REQUIRE(out.status == ResponseStatus::OK); +} + +TEST_CASE("Controller::send with CmdWatchdog returns StatusOK", "[controller][REQ-CTRL-014]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Watchdog}, out)); + REQUIRE(out.status == ResponseStatus::OK); +} + +TEST_CASE("Controller::send with CmdReset returns StatusOK", "[controller][REQ-CTRL-015]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Reset}, out)); + REQUIRE(out.status == ResponseStatus::OK); +} + +TEST_CASE("Publish on a closed controller does not throw or crash", "[controller][REQ-CTRL-017]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + ctrl.close(); + REQUIRE_NOTHROW(ctrl.publish({})); +} + +TEST_CASE("Concurrent send calls are data-race free", "[controller][REQ-CTRL-018]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + constexpr int THREADS = 8, ITERS = 100; + std::vector workers; + workers.reserve(THREADS); + std::atomic ok{0}; + for (int t = 0; t < THREADS; ++t) { + workers.emplace_back([&, t]() { + for (int i = 0; i < ITERS; ++i) { + Response out; + Command cmd{static_cast(t * ITERS + i), + Zone::FrontLeft, CommandType::Get}; + if (!ctrl.send(Context::background(), cmd, out)) ++ok; + } + }); + } + for (auto& w : workers) w.join(); + REQUIRE(ok == THREADS * ITERS); + ctrl.close(); +} + +TEST_CASE("Concurrent publish and subscribe are data-race free", "[controller][REQ-CTRL-019]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + std::atomic stop{false}; + std::thread publisher([&]() { + while (!stop) ctrl.publish({0xAB}); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + stop = true; + publisher.join(); + ctrl.close(); +} + +TEST_CASE("Status.Zone is correct for every published update", "[controller][REQ-CTRL-020][REQ-STAT-001]") { + legacy_mock::Controller ctrl(Zone::RearLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + ctrl.publish({}); + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->zone == Zone::RearLeft); + ctrl.close(); +} + +TEST_CASE("Status.Payload matches published payload", "[controller][REQ-CTRL-021][REQ-STAT-004]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + std::vector payload{0xDE, 0xAD, 0xBE, 0xEF}; + ctrl.publish(payload); + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->payload == payload); + ctrl.close(); +} + +TEST_CASE("Status.Healthy is true while controller is open", "[controller][REQ-CTRL-022][REQ-STAT-003]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + ctrl.publish({}); + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->healthy); + ctrl.close(); +} + +TEST_CASE("Controller::send with empty payload does not crash", "[controller][REQ-CTRL-024]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_NOTHROW(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); +} + +TEST_CASE("Controller::send returns ErrZoneMismatch for wrong zone", "[controller][REQ-CTRL-025]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontRight}, out) == ErrZoneMismatch); +} + +TEST_CASE("Payload is copied before passing to handler", "[controller][REQ-CTRL-026]") { + std::vector seen_payload; + legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command& c) -> Response { + seen_payload = c.payload; + return {c.id, c.zone, ResponseStatus::OK, {}}; + }); + std::vector payload{1, 2, 3}; + Command cmd{1, Zone::FrontLeft}; + cmd.payload = payload; + Response out; + ctrl.send(Context::background(), cmd, out); + payload[0] = 0xFF; + REQUIRE(seen_payload == std::vector{1, 2, 3}); +} + +TEST_CASE("Publish copies payload before delivering to subscribers", "[controller][REQ-CTRL-027]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + std::shared_ptr ch; + REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); + std::vector payload{0xAA, 0xBB}; + ctrl.publish(payload); + payload[0] = 0x00; + auto s = ch->recv(); + REQUIRE(s.has_value()); + REQUIRE(s->payload == std::vector{0xAA, 0xBB}); + ctrl.close(); +} + +// ── Response fields ─────────────────────────────────────────────────────────── + +TEST_CASE("Response.command_id echoes the originating Command.id", "[response][REQ-RESP-001]") { + legacy_mock::Controller ctrl(Zone::FrontLeft); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{0xDEADBEEF, Zone::FrontLeft}, out)); + REQUIRE(out.command_id == 0xDEADBEEF); +} + +TEST_CASE("Response.zone identifies the zone that processed the command", "[response][REQ-RESP-002]") { + legacy_mock::Controller ctrl(Zone::RearRight); + Response out; + REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::RearRight}, out)); + REQUIRE(out.zone == Zone::RearRight); +} diff --git a/tests/test_loan.cpp b/tests/test_loan.cpp index 9b9d23f..7f4ad6e 100644 --- a/tests/test_loan.cpp +++ b/tests/test_loan.cpp @@ -6,13 +6,13 @@ // fusa:test REQ-LOAN-006 #include -#include +#include #include using namespace rcp; -static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { + return std::make_shared(z); } // ── loan() ──────────────────────────────────────────────────────────────────── diff --git a/tests/test_mock.cpp b/tests/test_mock.cpp index 74c44b6..cb9c981 100644 --- a/tests/test_mock.cpp +++ b/tests/test_mock.cpp @@ -1,460 +1,192 @@ -// fusa:test REQ-CTRL-001 -// fusa:test REQ-CTRL-002 -// fusa:test REQ-CTRL-003 -// fusa:test REQ-CTRL-004 -// fusa:test REQ-CTRL-005 -// fusa:test REQ-CTRL-006 -// fusa:test REQ-CTRL-007 -// fusa:test REQ-CTRL-008 -// fusa:test REQ-CTRL-009 -// fusa:test REQ-CTRL-010 -// fusa:test REQ-CTRL-011 -// fusa:test REQ-CTRL-012 -// fusa:test REQ-CTRL-013 -// fusa:test REQ-CTRL-014 -// fusa:test REQ-CTRL-015 -// fusa:test REQ-CTRL-016 -// fusa:test REQ-CTRL-017 -// fusa:test REQ-CTRL-018 -// fusa:test REQ-CTRL-019 -// fusa:test REQ-CTRL-020 -// fusa:test REQ-CTRL-021 -// fusa:test REQ-CTRL-022 -// fusa:test REQ-CTRL-023 -// fusa:test REQ-CTRL-024 -// fusa:test REQ-CTRL-025 -// fusa:test REQ-CTRL-026 -// fusa:test REQ-CTRL-027 -// fusa:test REQ-REG-001 -// fusa:test REQ-REG-002 -// fusa:test REQ-REG-003 -// fusa:test REQ-REG-004 -// fusa:test REQ-REG-005 -// fusa:test REQ-REG-006 -// fusa:test REQ-REG-007 -// fusa:test REQ-REG-008 -// fusa:test REQ-REG-009 -// fusa:test REQ-REG-010 -// fusa:test REQ-REG-011 -// fusa:test REQ-REG-012 -// fusa:test REQ-REG-013 -// fusa:test REQ-RESP-001 -// fusa:test REQ-RESP-002 -// fusa:test REQ-STAT-001 -// fusa:test REQ-STAT-002 -// fusa:test REQ-STAT-003 -// fusa:test REQ-STAT-004 -// fusa:test REQ-ERR-011 +// fusa:test REQ-MOCK-001 +// fusa:test REQ-MOCK-002 +// fusa:test REQ-MOCK-003 +// fusa:test REQ-MOCK-004 +// fusa:test REQ-MOCK-005 +// fusa:test REQ-MOCK-006 +// fusa:test REQ-MOCK-007 +// fusa:test REQ-MOCK-008 +// fusa:test REQ-MOCK-009 +// fusa:test REQ-MOCK-010 + +// Tests for rcp/mock.hpp — the in-process RC Server simulator (ROADMAP.md +// milestone 56, "Test & Simulation Harness Rebuild", v2.12.0). See +// tests/test_legacy_mock.cpp for the pre-replacement Controller/Registry +// coverage that used to live in this file. #include #include -#include -#include -#include -#include - using namespace rcp; - -// ── Registry pre-population ─────────────────────────────────────────────────── - -TEST_CASE("NewRegistry pre-populates all five standard zones", "[registry][REQ-REG-001]") { - auto reg = mock::new_registry(); - for (auto z : {Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central}) { - std::shared_ptr ctrl; - REQUIRE_FALSE(reg->lookup(z, ctrl)); - REQUIRE(ctrl->zone() == z); +using rcp::endpoint::WriteSemantics; + +namespace { + +acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, bool write, uint8_t evt_op = 0, + bool evt_ack = false) { + auto req = acf::make_standard_request(bus_id, /*transaction_num=*/1, write, /*read_size=*/0); + req.evt_op = evt_op; + req.evt_ack = evt_ack; + return req; +} + +} // namespace + +// ── Construction / register map ────────────────────────────────────────────── + +TEST_CASE("Server starts HW_UNCONFIGURED with a two-endpoint register map", + "[mock][REQ-MOCK-001]") { + mock::Server server; + REQUIRE(server.lifecycle().state() == lifecycle::ServerState::HwUnconfigured); + REQUIRE(server.registers().endpoint_count == 2); + REQUIRE(server.registers().generic_configs.size() == 2); + REQUIRE(server.registers().functional_configs.size() == 2); + REQUIRE(server.registers().ep_id_mapping.size() == 2); + REQUIRE(server.registers().ep_id_mapping[0].ep_id == mock::kGpioEndpointId); + REQUIRE(server.registers().ep_id_mapping[0].byte_bus_id == mock::kGpioByteBusId); + REQUIRE(server.registers().ep_id_mapping[1].ep_id == mock::kSpiEndpointId); + REQUIRE(server.registers().ep_id_mapping[1].byte_bus_id == mock::kSpiByteBusId); +} + +TEST_CASE("advance_to_rcp_configured drives the lifecycle straight to RCP_CONFIGURED", + "[mock][REQ-MOCK-002]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + REQUIRE(server.lifecycle().state() == lifecycle::ServerState::RcpConfigured); +} + +// ── EP0 ─────────────────────────────────────────────────────────────────────── + +TEST_CASE("EP0 read answers the register map's magic number for any client", + "[mock][REQ-MOCK-003]") { + mock::Server server; + for (size_t client : {size_t{0}, size_t{1}, size_t{42}}) { + auto req = standard_request(regmap::kEp0, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(client, req, {}, resp, resp_payload); + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); + REQUIRE(resp_payload.size() == mock::kEp0PartialReadLen); + REQUIRE(avtp::detail::get_u32(resp_payload.data()) == server.registers().magic); } - reg->close(); -} - -TEST_CASE("Registry::register_ctrl returns ErrAlreadyExists for duplicate zone", "[registry][REQ-REG-002]") { - auto reg = mock::new_registry(); - auto dup = std::make_shared(Zone::FrontLeft); - REQUIRE(reg->register_ctrl(dup) == ErrAlreadyExists); - reg->close(); -} - -TEST_CASE("Registry::deregister removes and closes the controller", "[registry][REQ-REG-003]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); - reg->close(); -} - -TEST_CASE("Registry::lookup returns ErrNotFound for absent zone", "[registry][REQ-REG-004]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); - reg->close(); -} - -TEST_CASE("Registry::close is idempotent", "[registry][REQ-REG-005]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->close()); - REQUIRE_FALSE(reg->close()); -} - -TEST_CASE("Registry::controllers returns all registered controllers", "[registry][REQ-REG-006]") { - auto reg = mock::new_registry(); - auto ctrls = reg->controllers(); - REQUIRE(ctrls.size() == 5); - reg->close(); -} - -TEST_CASE("Registry::register_ctrl returns ErrClosed after close", "[registry][REQ-REG-007]") { - auto reg = mock::new_registry(); - reg->close(); - auto extra = std::make_shared(Zone::FrontLeft); - REQUIRE(reg->register_ctrl(extra) == ErrClosed); } -TEST_CASE("Registry::deregister returns ErrNotFound for never-registered zone", "[registry][REQ-REG-008]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); - reg->close(); -} +TEST_CASE("EP0 write via dispatch() is always rejected", "[mock][REQ-MOCK-004]") { + mock::Server server; + REQUIRE_FALSE(server.ep0().claim_root_client(/*client=*/1)); -TEST_CASE("Registered controller is immediately retrievable via lookup", "[registry][REQ-REG-009]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - auto nc = std::make_shared(Zone::FrontLeft); - REQUIRE_FALSE(reg->register_ctrl(nc)); - std::shared_ptr got; - REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, got)); - REQUIRE(got.get() == nc.get()); - reg->close(); + auto req = standard_request(regmap::kEp0, /*write=*/true); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(/*client=*/1, req, {}, resp, resp_payload); + REQUIRE(ec == regmap::make_error_code(regmap::RegMapErrc::request_rejected)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); } -TEST_CASE("Registry::close closes all registered controllers", "[registry][REQ-REG-010]") { - auto reg = mock::new_registry(); - std::shared_ptr ctrl; - REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, ctrl)); - reg->close(); - Response out; - auto ec = ctrl->send(Context::background(), Command{0, Zone::FrontLeft}, out); - REQUIRE(ec == ErrClosed); -} +TEST_CASE("write_whole_map requires the root client", "[mock][REQ-MOCK-005]") { + mock::Server server; + REQUIRE_FALSE(server.ep0().claim_root_client(/*client=*/1)); + auto claim_ec = server.ep0().claim_root_client(/*client=*/2); + REQUIRE(claim_ec == regmap::make_error_code(regmap::RegMapErrc::request_rejected)); -TEST_CASE("Registry::lookup on closed registry returns ErrClosed", "[registry][REQ-REG-011][REQ-REG-013]") { - auto reg = mock::new_registry(); - reg->close(); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrClosed); -} + regmap::RegisterMap replacement = server.registers(); + replacement.vendor_id = 0x1234; -TEST_CASE("Registry::deregister for already-deregistered zone returns ErrNotFound", "[registry][REQ-REG-012]") { - auto reg = mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); - reg->close(); + auto write_ec = server.ep0().write_whole_map(/*client=*/2, replacement); + REQUIRE(write_ec == regmap::make_error_code(regmap::RegMapErrc::unauthorized_access)); + REQUIRE_FALSE(server.ep0().write_whole_map(/*client=*/1, replacement)); + REQUIRE(server.registers().vendor_id == 0x1234); } -// ── Controller::send ────────────────────────────────────────────────────────── +// ── Operational-request gating ─────────────────────────────────────────────── -TEST_CASE("Controller::send returns StatusOK with no handler", "[controller][REQ-CTRL-001]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} +TEST_CASE("GPIO/SPI requests are rejected before RCP_CONFIGURED", "[mock][REQ-MOCK-006]") { + mock::Server server; + REQUIRE(server.lifecycle().state() == lifecycle::ServerState::HwUnconfigured); + auto rejected = regmap::make_error_code(regmap::RegMapErrc::request_rejected); -TEST_CASE("Controller::send dispatches to handler and returns response verbatim", "[controller][REQ-CTRL-002][REQ-CTRL-016]") { - bool called = false; - mock::Controller ctrl(Zone::FrontLeft, [&](const Command& cmd) -> Response { - called = true; - return Response{cmd.id, cmd.zone, ResponseStatus::Error, {}}; - }); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{42, Zone::FrontLeft}, out)); - REQUIRE(called); - REQUIRE(out.status == ResponseStatus::Error); - REQUIRE(out.command_id == 42); -} + auto gpio_req = standard_request(mock::kGpioByteBusId, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE(server.dispatch(0, gpio_req, {}, resp, resp_payload) == rejected); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); -TEST_CASE("Controller::send returns ErrClosed after close", "[controller][REQ-CTRL-003]") { - mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - Response out; - REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out) == ErrClosed); + auto spi_req = standard_request(mock::kSpiByteBusId, /*write=*/false); + REQUIRE(server.dispatch(0, spi_req, {}, resp, resp_payload) == rejected); } -TEST_CASE("Controller::send returns ErrTimeout when ctx is already done", "[controller][REQ-CTRL-004][REQ-CTRL-023]") { - bool handler_called = false; - mock::Controller ctrl(Zone::FrontLeft, [&](const Command&) -> Response { - handler_called = true; - return {}; - }); - auto past = Context::with_deadline( - std::chrono::steady_clock::now() - std::chrono::seconds(1)); - Response out; - REQUIRE(ctrl.send(past, Command{1, Zone::FrontLeft}, out) == ErrTimeout); - REQUIRE_FALSE(handler_called); -} +// ── GPIO ────────────────────────────────────────────────────────────────────── -TEST_CASE("Controller::close is idempotent", "[controller][REQ-CTRL-005]") { - mock::Controller ctrl(Zone::FrontLeft); - REQUIRE_FALSE(ctrl.close()); - REQUIRE_FALSE(ctrl.close()); -} +TEST_CASE("GPIO write applies evt[2:0] semantics and read reflects the new state", + "[mock][REQ-MOCK-007]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); -TEST_CASE("Controller::subscribe channel closes when controller closes", "[controller][REQ-CTRL-007]") { - auto ctrl = std::make_unique(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl->subscribe(Context::background(), ch)); - ctrl->close(); - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - REQUIRE(ch->is_closed()); -} + auto write_req = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Or)); + auto payload = gpio::encode_gpio_payload(0x0000'000F); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(server.dispatch(0, write_req, payload, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::WriteResponse); + gpio::PinMask written = 0; + REQUIRE_FALSE(gpio::decode_gpio_payload(resp_payload.data(), resp_payload.size(), written)); + REQUIRE(written == 0x0000'000F); -TEST_CASE("Controller::subscribe returns ErrClosed after close", "[controller][REQ-CTRL-008]") { - mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - std::shared_ptr ch; - REQUIRE(ctrl.subscribe(Context::background(), ch) == ErrClosed); + auto read_req = standard_request(mock::kGpioByteBusId, /*write=*/false); + REQUIRE_FALSE(server.dispatch(0, read_req, {}, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); + gpio::PinMask read_back = 0; + REQUIRE_FALSE(gpio::decode_gpio_payload(resp_payload.data(), resp_payload.size(), read_back)); + REQUIRE(read_back == 0x0000'000F); + REQUIRE(server.gpio().read() == 0x0000'000F); } -TEST_CASE("Controller::subscribe concurrent with close is race-free", "[controller][REQ-CTRL-008]") { - // Regression test: subscribe() and close() used to check `closed_` and - // mutate `subs_` under separate critical sections, so a subscribe() could - // observe "open" and still add its channel to subs_ after close() had - // already run and cleared it — the added channel would only self-heal - // later via its own watcher thread polling `closed_`, not synchronously - // as part of close(). Now both are serialized under the same mutex, so - // whenever a subscribe() call succeeds concurrently with close(), close() - // is guaranteed to see (and synchronously close) that channel itself — - // checkable immediately after both threads join, with no sleep. - for (int iter = 0; iter < 200; ++iter) { - auto ctrl = std::make_unique(Zone::FrontLeft); - - std::vector> channels; - std::mutex channels_mu; - - std::vector subscribers; - for (int i = 0; i < 4; ++i) { - subscribers.emplace_back([&] { - std::shared_ptr ch; - if (!ctrl->subscribe(Context::background(), ch)) { - std::lock_guard lk(channels_mu); - channels.push_back(std::move(ch)); - } - }); - } - std::thread closer([&] { ctrl->close(); }); - - for (auto& t : subscribers) t.join(); - closer.join(); - - // No sleep: with the fix, every channel a successful subscribe() handed - // back is guaranteed closed by the time close() has returned, because - // close() (having won or lost the race under the shared lock) either - // never sees the channel or closes it itself before releasing the lock. - for (auto& ch : channels) { - REQUIRE(ch->is_closed()); - } - } -} +TEST_CASE("GPIO write with evt_ack set produces an Acknowledge response", + "[mock][REQ-MOCK-008]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); -TEST_CASE("Controller::zone returns declared zone", "[controller][REQ-CTRL-009]") { - mock::Controller ctrl(Zone::RearRight); - REQUIRE(ctrl.zone() == Zone::RearRight); + auto req = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Replace), /*evt_ack=*/true); + auto payload = gpio::encode_gpio_payload(0x1); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(server.dispatch(0, req, payload, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::Acknowledge); } -TEST_CASE("Status Seq values are strictly increasing", "[controller][REQ-CTRL-010][REQ-STAT-002]") { - mock::Controller ctrl(Zone::Central); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - ctrl.publish({}); - ctrl.publish({}); - uint32_t last = 0; - for (int i = 0; i < 3; ++i) { - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->seq > last); - last = s->seq; - } - ctrl.close(); -} +// ── SPI ─────────────────────────────────────────────────────────────────────── -TEST_CASE("Subscribe channel is closed when its context expires", "[controller][REQ-CTRL-011]") { - mock::Controller ctrl(Zone::FrontLeft); - auto ctx = Context::with_timeout(std::chrono::milliseconds(20)); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(ctx, ch)); - std::this_thread::sleep_for(std::chrono::milliseconds(60)); - REQUIRE(ch->is_closed()); - ctrl.close(); -} +TEST_CASE("SPI transfer answers with the scripted POCI-in bytes for the addressed channel", + "[mock][REQ-MOCK-009]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); -TEST_CASE("Multiple concurrent subscribers each receive published status", "[controller][REQ-CTRL-006][REQ-CTRL-012]") { - mock::Controller ctrl(Zone::FrontLeft); - constexpr int N = 3; - std::vector> channels(N); - for (auto& ch : channels) REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({0x01}); - for (auto& ch : channels) { - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->zone == Zone::FrontLeft); - REQUIRE(s->payload == std::vector{0x01}); - } - ctrl.close(); -} + server.set_spi_poci(/*channel=*/2, {0xDE, 0xAD, 0xBE, 0xEF}); -TEST_CASE("Controller::send with CmdNoop returns StatusOK", "[controller][REQ-CTRL-013]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Noop}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Controller::send with CmdWatchdog returns StatusOK", "[controller][REQ-CTRL-014]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Watchdog}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Controller::send with CmdReset returns StatusOK", "[controller][REQ-CTRL-015]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Reset}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Publish on a closed controller does not throw or crash", "[controller][REQ-CTRL-017]") { - mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - REQUIRE_NOTHROW(ctrl.publish({})); -} - -TEST_CASE("Concurrent send calls are data-race free", "[controller][REQ-CTRL-018]") { - mock::Controller ctrl(Zone::FrontLeft); - constexpr int THREADS = 8, ITERS = 100; - std::vector workers; - workers.reserve(THREADS); - std::atomic ok{0}; - for (int t = 0; t < THREADS; ++t) { - workers.emplace_back([&, t]() { - for (int i = 0; i < ITERS; ++i) { - Response out; - Command cmd{static_cast(t * ITERS + i), - Zone::FrontLeft, CommandType::Get}; - if (!ctrl.send(Context::background(), cmd, out)) ++ok; - } - }); - } - for (auto& w : workers) w.join(); - REQUIRE(ok == THREADS * ITERS); - ctrl.close(); -} - -TEST_CASE("Concurrent publish and subscribe are data-race free", "[controller][REQ-CTRL-019]") { - mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::atomic stop{false}; - std::thread publisher([&]() { - while (!stop) ctrl.publish({0xAB}); - }); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - stop = true; - publisher.join(); - ctrl.close(); -} - -TEST_CASE("Status.Zone is correct for every published update", "[controller][REQ-CTRL-020][REQ-STAT-001]") { - mock::Controller ctrl(Zone::RearLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->zone == Zone::RearLeft); - ctrl.close(); -} - -TEST_CASE("Status.Payload matches published payload", "[controller][REQ-CTRL-021][REQ-STAT-004]") { - mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::vector payload{0xDE, 0xAD, 0xBE, 0xEF}; - ctrl.publish(payload); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->payload == payload); - ctrl.close(); -} - -TEST_CASE("Status.Healthy is true while controller is open", "[controller][REQ-CTRL-022][REQ-STAT-003]") { - mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->healthy); - ctrl.close(); -} - -TEST_CASE("Controller::send with empty payload does not crash", "[controller][REQ-CTRL-024]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_NOTHROW(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); -} - -TEST_CASE("Controller::send returns ErrZoneMismatch for wrong zone", "[controller][REQ-CTRL-025]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontRight}, out) == ErrZoneMismatch); -} - -TEST_CASE("Payload is copied before passing to handler", "[controller][REQ-CTRL-026]") { - std::vector seen_payload; - mock::Controller ctrl(Zone::FrontLeft, [&](const Command& c) -> Response { - seen_payload = c.payload; - return {c.id, c.zone, ResponseStatus::OK, {}}; - }); - std::vector payload{1, 2, 3}; - Command cmd{1, Zone::FrontLeft}; - cmd.payload = payload; - Response out; - ctrl.send(Context::background(), cmd, out); - payload[0] = 0xFF; - REQUIRE(seen_payload == std::vector{1, 2, 3}); -} - -TEST_CASE("Publish copies payload before delivering to subscribers", "[controller][REQ-CTRL-027]") { - mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::vector payload{0xAA, 0xBB}; - ctrl.publish(payload); - payload[0] = 0x00; - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->payload == std::vector{0xAA, 0xBB}); - ctrl.close(); -} - -// ── Response fields ─────────────────────────────────────────────────────────── - -TEST_CASE("Response.command_id echoes the originating Command.id", "[response][REQ-RESP-001]") { - mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{0xDEADBEEF, Zone::FrontLeft}, out)); - REQUIRE(out.command_id == 0xDEADBEEF); -} + auto req = standard_request(mock::kSpiByteBusId, /*write=*/true, /*evt_op=*/2); + std::vector pico_out{0x01, 0x02}; + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(server.dispatch(0, req, pico_out, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); + REQUIRE(resp_payload == std::vector{0xDE, 0xAD, 0xBE, 0xEF}); + REQUIRE(server.spi().last_sent(2) == pico_out); +} + +// ── Unmapped endpoints ──────────────────────────────────────────────────────── + +TEST_CASE("dispatch to an unmapped byte_bus_id reports invalid_parameter", + "[mock][REQ-MOCK-010]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); -TEST_CASE("Response.zone identifies the zone that processed the command", "[response][REQ-RESP-002]") { - mock::Controller ctrl(Zone::RearRight); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::RearRight}, out)); - REQUIRE(out.zone == Zone::RearRight); + auto req = standard_request(/*bus_id=*/99, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {}, resp, resp_payload); + REQUIRE(ec == regmap::make_error_code(regmap::RegMapErrc::invalid_parameter)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); } diff --git a/tests/test_observe.cpp b/tests/test_observe.cpp index 2b6ce07..f8394d7 100644 --- a/tests/test_observe.cpp +++ b/tests/test_observe.cpp @@ -8,7 +8,7 @@ // fusa:test REQ-OBS-008 #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/observe.hpp" #include @@ -51,7 +51,7 @@ class CountingSink final : public observe::MetricsSink { TEST_CASE("observe: span recorded on successful send", "[observe]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = observe::new_controller(inner, sink); Command cmd; @@ -69,7 +69,7 @@ TEST_CASE("observe: span recorded on successful send", "[observe]") { TEST_CASE("observe: multiple sends accumulate spans", "[observe]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = observe::new_controller(inner, sink); for (int i = 0; i < 5; ++i) { @@ -86,7 +86,7 @@ TEST_CASE("observe: multiple sends accumulate spans", "[observe]") { TEST_CASE("observe: span duration is non-negative", "[observe]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::Central); + auto inner = std::make_shared(Zone::Central); auto ctrl = observe::new_controller(inner, sink); Command cmd; @@ -101,7 +101,7 @@ TEST_CASE("observe: span duration is non-negative", "[observe]") { } TEST_CASE("observe: noop sink does not crash", "[observe][REQ-OBS-005]") { - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = observe::new_controller(inner); // default noop sink Command cmd; @@ -113,7 +113,7 @@ TEST_CASE("observe: noop sink does not crash", "[observe][REQ-OBS-005]") { TEST_CASE("observe: commands.total counter increments per send", "[observe][REQ-OBS-007]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = observe::new_controller(inner, sink); for (int i = 0; i < 3; ++i) { @@ -128,7 +128,7 @@ TEST_CASE("observe: commands.total counter increments per send", "[observe][REQ- TEST_CASE("observe: span captures error code and error counter increments on failure", "[observe][REQ-OBS-004][REQ-OBS-008]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); REQUIRE_FALSE(inner->close()); // a closed controller fails every send with ErrClosed auto ctrl = observe::new_controller(inner, sink); @@ -145,7 +145,7 @@ TEST_CASE("observe: span captures error code and error counter increments on fai TEST_CASE("observe: InMemorySink is thread-safe under concurrent spans", "[observe][REQ-OBS-006]") { auto sink = std::make_shared(); - auto inner = std::make_shared(Zone::Central); + auto inner = std::make_shared(Zone::Central); auto ctrl = observe::new_controller(inner, sink); constexpr int kThreads = 8; diff --git a/tests/test_prioqueue.cpp b/tests/test_prioqueue.cpp index 93f8dc6..0678af3 100644 --- a/tests/test_prioqueue.cpp +++ b/tests/test_prioqueue.cpp @@ -8,13 +8,13 @@ // fusa:test REQ-PQ-008 #include -#include +#include #include using namespace rcp; -static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { - auto ctrl = std::make_shared(z); +static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { + auto ctrl = std::make_shared(z); return ctrl; } diff --git a/tests/test_proxy.cpp b/tests/test_proxy.cpp index 598799f..757f592 100644 --- a/tests/test_proxy.cpp +++ b/tests/test_proxy.cpp @@ -6,7 +6,7 @@ // fusa:test REQ-PROXY-006 #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/proxy.hpp" #include @@ -14,7 +14,7 @@ using namespace rcp; TEST_CASE("proxy: ProxyController forwards command within budget", "[proxy][REQ-PROXY-001]") { - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); proxy::ProxyController pc(inner); // default 50ms budget — not exceeded Command cmd; @@ -26,7 +26,7 @@ TEST_CASE("proxy: ProxyController forwards command within budget", "[proxy][REQ- } TEST_CASE("proxy: zero latency budget makes the hop time out", "[proxy][REQ-PROXY-001]") { - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); proxy::Config cfg; cfg.latency_budget = std::chrono::milliseconds(0); // deadline = now → already due proxy::ProxyController pc(inner, cfg); @@ -41,14 +41,14 @@ TEST_CASE("proxy: zero latency budget makes the hop time out", "[proxy][REQ-PROX } TEST_CASE("proxy: ProxyController zone matches upstream", "[proxy][REQ-PROXY-006]") { - auto inner = std::make_shared(Zone::RearRight); + auto inner = std::make_shared(Zone::RearRight); proxy::ProxyController pc(inner); REQUIRE(pc.zone() == Zone::RearRight); } TEST_CASE("proxy: ProxyRegistry lookup and send", "[proxy][REQ-PROXY-002]") { auto reg = proxy::new_registry(); - auto inner = std::make_shared(Zone::RearLeft); + auto inner = std::make_shared(Zone::RearLeft); REQUIRE_FALSE(reg->add_route(inner)); std::shared_ptr ctrl; @@ -70,7 +70,7 @@ TEST_CASE("proxy: lookup unknown zone returns ErrNotFound", "[proxy][REQ-PROXY-0 TEST_CASE("proxy: deregister closes the upstream controller", "[proxy][REQ-PROXY-003]") { auto reg = proxy::new_registry(); - auto inner = std::make_shared(Zone::RearLeft); + auto inner = std::make_shared(Zone::RearLeft); REQUIRE_FALSE(reg->add_route(inner)); REQUIRE_FALSE(reg->deregister(Zone::RearLeft)); @@ -86,7 +86,7 @@ TEST_CASE("proxy: deregister closes the upstream controller", "[proxy][REQ-PROXY TEST_CASE("proxy: ProxyRegistry close is idempotent", "[proxy][REQ-PROXY-005]") { auto reg = proxy::new_registry(); - auto inner = std::make_shared(Zone::FrontRight); + auto inner = std::make_shared(Zone::FrontRight); REQUIRE_FALSE(reg->add_route(inner)); REQUIRE_FALSE(reg->close()); REQUIRE_FALSE(reg->close()); // second close is a no-op, still no error @@ -94,15 +94,15 @@ TEST_CASE("proxy: ProxyRegistry close is idempotent", "[proxy][REQ-PROXY-005]") TEST_CASE("proxy: duplicate route returns ErrAlreadyExists", "[proxy]") { auto reg = proxy::new_registry(); - auto a = std::make_shared(Zone::FrontLeft); - auto b = std::make_shared(Zone::FrontLeft); + auto a = std::make_shared(Zone::FrontLeft); + auto b = std::make_shared(Zone::FrontLeft); REQUIRE_FALSE(reg->add_route(a)); REQUIRE(reg->add_route(b) == ErrAlreadyExists); } TEST_CASE("proxy: close closes all routes", "[proxy]") { auto reg = proxy::new_registry(); - auto inner = std::make_shared(Zone::FrontRight); + auto inner = std::make_shared(Zone::FrontRight); REQUIRE_FALSE(reg->add_route(inner)); REQUIRE_FALSE(reg->close()); } diff --git a/tests/test_record.cpp b/tests/test_record.cpp index 9fd3911..93899bc 100644 --- a/tests/test_record.cpp +++ b/tests/test_record.cpp @@ -8,7 +8,7 @@ // fusa:test REQ-REC-008 #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/record.hpp" #include @@ -19,7 +19,7 @@ using namespace rcp; TEST_CASE("record: recording controller captures entries", "[record]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = record::new_controller(inner, rec); Command cmd; @@ -35,7 +35,7 @@ TEST_CASE("record: recording controller captures entries", "[record]") { TEST_CASE("record: multiple sends produce sequential entries", "[record]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = record::new_controller(inner, rec); for (int i = 0; i < 3; ++i) { @@ -52,7 +52,7 @@ TEST_CASE("record: multiple sends produce sequential entries", "[record]") { TEST_CASE("record: write_binary creates file", "[record]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = record::new_controller(inner, rec); Command cmd; @@ -73,7 +73,7 @@ TEST_CASE("record: write_binary creates file", "[record]") { TEST_CASE("record: entry timestamps are monotonically non-decreasing", "[record][REQ-REC-006]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = record::new_controller(inner, rec); for (int i = 0; i < 20; ++i) { @@ -92,7 +92,7 @@ TEST_CASE("record: entry timestamps are monotonically non-decreasing", "[record] TEST_CASE("record: forwards inner send result unchanged", "[record][REQ-REC-007]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); REQUIRE_FALSE(inner->close()); // closed inner returns ErrClosed auto ctrl = record::new_controller(inner, rec); @@ -106,7 +106,7 @@ TEST_CASE("record: forwards inner send result unchanged", "[record][REQ-REC-007] TEST_CASE("record: Record tolerates concurrent appends", "[record][REQ-REC-008]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::Central); + auto inner = std::make_shared(Zone::Central); auto ctrl = record::new_controller(inner, rec); constexpr int kThreads = 8; @@ -128,7 +128,7 @@ TEST_CASE("record: Record tolerates concurrent appends", "[record][REQ-REC-008]" TEST_CASE("record: playback replays entries against target", "[record][REQ-REC-004][REQ-REC-005]") { auto rec = std::make_shared(); - auto inner = std::make_shared(Zone::FrontLeft); + auto inner = std::make_shared(Zone::FrontLeft); auto ctrl = record::new_controller(inner, rec); Command cmd; @@ -140,7 +140,7 @@ TEST_CASE("record: playback replays entries against target", "[record][REQ-REC-0 REQUIRE(rec->size() == 1); - auto target = std::make_shared(Zone::FrontLeft); + auto target = std::make_shared(Zone::FrontLeft); record::Playback pb(target, *rec, {0.0}); // no delays REQUIRE_FALSE(pb.run_all(Context{})); } diff --git a/tests/test_redundancy.cpp b/tests/test_redundancy.cpp index f5f7f64..a000090 100644 --- a/tests/test_redundancy.cpp +++ b/tests/test_redundancy.cpp @@ -8,7 +8,7 @@ // fusa:test REQ-RED-008 #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/redundancy.hpp" #include @@ -34,8 +34,8 @@ class FailController final : public rcp::Controller { } // namespace TEST_CASE("redundancy: primary succeeds, standby unused", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); + auto primary = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); auto ctrl = redundancy::new_controller(primary, standby); REQUIRE(ctrl->is_primary_active()); @@ -49,8 +49,8 @@ TEST_CASE("redundancy: primary succeeds, standby unused", "[redundancy]") { } TEST_CASE("redundancy: manual promote switches to standby", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); + auto primary = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); auto ctrl = redundancy::new_controller(primary, standby); ctrl->promote(); @@ -64,8 +64,8 @@ TEST_CASE("redundancy: manual promote switches to standby", "[redundancy]") { } TEST_CASE("redundancy: double promote returns to primary", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); + auto primary = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); auto ctrl = redundancy::new_controller(primary, standby); ctrl->promote(); @@ -74,15 +74,15 @@ TEST_CASE("redundancy: double promote returns to primary", "[redundancy]") { } TEST_CASE("redundancy: zone() returns zone of active controller", "[redundancy][REQ-RED-008]") { - auto primary = std::make_shared(Zone::RearRight); - auto standby = std::make_shared(Zone::RearRight); + auto primary = std::make_shared(Zone::RearRight); + auto standby = std::make_shared(Zone::RearRight); auto ctrl = redundancy::new_controller(primary, standby); REQUIRE(ctrl->zone() == Zone::RearRight); } TEST_CASE("redundancy: auto-promotes standby on ErrClosed", "[redundancy][REQ-RED-002]") { auto primary = std::make_shared(Zone::FrontLeft, ErrClosed); - auto standby = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); auto ctrl = redundancy::new_controller(primary, standby); REQUIRE(ctrl->is_primary_active()); @@ -94,7 +94,7 @@ TEST_CASE("redundancy: auto-promotes standby on ErrClosed", "[redundancy][REQ-RE TEST_CASE("redundancy: auto-promotes standby on ErrTimeout", "[redundancy][REQ-RED-003]") { auto primary = std::make_shared(Zone::FrontLeft, ErrTimeout); - auto standby = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); auto ctrl = redundancy::new_controller(primary, standby); Command cmd; cmd.zone = Zone::FrontLeft; cmd.type = CommandType::Get; @@ -106,7 +106,7 @@ TEST_CASE("redundancy: auto-promotes standby on ErrTimeout", "[redundancy][REQ-R TEST_CASE("redundancy: auto_promote=false disables automatic failover", "[redundancy][REQ-RED-007]") { auto primary = std::make_shared(Zone::FrontLeft, ErrClosed); - auto standby = std::make_shared(Zone::FrontLeft); + auto standby = std::make_shared(Zone::FrontLeft); redundancy::Config cfg; cfg.auto_promote = false; auto ctrl = redundancy::new_controller(primary, standby, cfg); @@ -118,8 +118,8 @@ TEST_CASE("redundancy: auto_promote=false disables automatic failover", } TEST_CASE("redundancy: close() closes both controllers", "[redundancy][REQ-RED-006]") { - auto primary = std::make_shared(Zone::Central); - auto standby = std::make_shared(Zone::Central); + auto primary = std::make_shared(Zone::Central); + auto standby = std::make_shared(Zone::Central); auto ctrl = redundancy::new_controller(primary, standby); REQUIRE_FALSE(ctrl->close()); diff --git a/tests/test_relay.cpp b/tests/test_relay.cpp index 4ea32df..1fc180c 100644 --- a/tests/test_relay.cpp +++ b/tests/test_relay.cpp @@ -11,7 +11,7 @@ #include #include -#include +#include #include #include @@ -143,19 +143,19 @@ TEST_CASE("relay: Channel recv returns nullopt after close with empty queue", "[ // ── §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 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 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 ctrl = std::make_shared(rcp::Zone::FrontLeft); auto caller = rcp::Adapt(ctrl); relay::Message req; @@ -171,7 +171,7 @@ TEST_CASE("relay: Adapt() call() sends command and returns response", "[relay][a } TEST_CASE("relay: Adapt() send() succeeds", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); auto caller = rcp::Adapt(ctrl); relay::Message msg; @@ -183,7 +183,7 @@ TEST_CASE("relay: Adapt() send() succeeds", "[relay][adapt]") { } TEST_CASE("relay: Adapt() subscribe() returns valid channel", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); auto caller = rcp::Adapt(ctrl); auto [ch, ec] = caller->subscribe(); @@ -213,7 +213,7 @@ TEST_CASE("relay: Adapt() subscribe() returns valid channel", "[relay][adapt]") } TEST_CASE("relay: Adapt() close() idempotent", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + 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 @@ -265,7 +265,7 @@ TEST_CASE("relay: SubscriberOptions has topic_name field, default empty", REQUIRE(opts.topic_name.empty()); // RCP adapter must ignore topic_name (only DDS routes on it, §14.1). - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); + auto ctrl = std::make_shared(rcp::Zone::FrontLeft); auto caller = rcp::Adapt(ctrl); opts.topic_name = "ignored-by-rcp"; auto [ch, ec] = caller->subscribe(opts); diff --git a/tests/test_sim.cpp b/tests/test_sim.cpp index ca7a881..ab9bf08 100644 --- a/tests/test_sim.cpp +++ b/tests/test_sim.cpp @@ -5,120 +5,141 @@ // fusa:test REQ-SIM-005 // fusa:test REQ-SIM-006 // fusa:test REQ-SIM-007 -// fusa:test REQ-SIM-008 + +// Tests for rcp/sim.hpp — the timing-realistic RC Server simulator +// (ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild", v2.12.0). #include #include using namespace rcp; -// ── Basic send ──────────────────────────────────────────────────────────────── +namespace { -TEST_CASE("sim Controller::send returns OK by default", "[sim][REQ-SIM-001]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - cfg.latency_model = sim::LatencyModel::Constant; - cfg.base_latency = std::chrono::milliseconds(0); - - sim::Controller ctrl(cfg); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(ctrl.send(Context::background(), cmd, resp)); - REQUIRE(resp.status == ResponseStatus::OK); +acf::AcfMessageInfo gpio_read_request() { + return acf::make_standard_request(mock::kGpioByteBusId, /*transaction_num=*/1, + /*write=*/false, /*read_size=*/0); } -TEST_CASE("sim Controller::zone returns configured zone", "[sim][REQ-SIM-002]") { - sim::Config cfg; - cfg.zone = Zone::RearRight; - sim::Controller ctrl(cfg); - REQUIRE(ctrl.zone() == Zone::RearRight); -} +} // namespace -// ── Latency ─────────────────────────────────────────────────────────────────── +// ── dispatch forwards to mock::Server ───────────────────────────────────────── -TEST_CASE("sim Controller applies constant latency", "[sim][REQ-SIM-003]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - cfg.latency_model = sim::LatencyModel::Constant; - cfg.base_latency = std::chrono::milliseconds(10); +TEST_CASE("dispatch forwards to the wrapped mock::Server by default", "[sim][REQ-SIM-001]") { + sim::Simulator sim; + REQUIRE_FALSE(sim.server().advance_to_rcp_configured()); - sim::Controller ctrl(cfg); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; + auto req = gpio_read_request(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(sim.dispatch(/*stream_key=*/1, /*client=*/0, req, {}, resp, resp_payload, + /*now_ms=*/0)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); +} - auto start = std::chrono::steady_clock::now(); - { auto ec = ctrl.send(Context::background(), cmd, resp); (void)ec; } - auto elapsed = std::chrono::steady_clock::now() - start; +// ── Fault injection ─────────────────────────────────────────────────────────── - REQUIRE(elapsed >= std::chrono::milliseconds(10)); +TEST_CASE("fault() causes dispatch to fail without touching the server", + "[sim][REQ-SIM-002]") { + sim::Simulator sim; + REQUIRE_FALSE(sim.server().advance_to_rcp_configured()); + sim.fault(std::make_error_code(std::errc::connection_reset)); + REQUIRE(sim.faulted()); + + auto req = gpio_read_request(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = sim.dispatch(1, 0, req, {}, resp, resp_payload, 0); + REQUIRE(ec == std::make_error_code(std::errc::connection_reset)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); } -// ── Fault injection ─────────────────────────────────────────────────────────── +TEST_CASE("recover() restores normal dispatch", "[sim][REQ-SIM-003]") { + sim::Simulator sim; + REQUIRE_FALSE(sim.server().advance_to_rcp_configured()); + sim.fault(std::make_error_code(std::errc::connection_reset)); + sim.recover(); + REQUIRE_FALSE(sim.faulted()); + + auto req = gpio_read_request(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(sim.dispatch(1, 0, req, {}, resp, resp_payload, 0)); +} -TEST_CASE("sim Controller::fault causes send to fail", "[sim][REQ-SIM-004]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - sim::Controller ctrl(cfg); +// ── Latency modeling ────────────────────────────────────────────────────────── - ctrl.fault(ErrClosed); +TEST_CASE("Constant latency model always reports base_latency", "[sim][REQ-SIM-004]") { + sim::Config cfg; + cfg.latency_model = sim::LatencyModel::Constant; + cfg.base_latency = std::chrono::milliseconds(7); + sim::Simulator sim(cfg); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE(ctrl.send(Context::background(), cmd, resp)); + for (int i = 0; i < 5; ++i) { + REQUIRE(sim.simulated_latency_ms() == std::chrono::milliseconds(7)); + } } -TEST_CASE("sim Controller::recover restores send to OK", "[sim][REQ-SIM-005]") { +TEST_CASE("Jitter latency model reports a delay within [base_latency, base_latency+jitter]", + "[sim][REQ-SIM-005]") { sim::Config cfg; - cfg.zone = Zone::FrontLeft; - sim::Controller ctrl(cfg); + cfg.latency_model = sim::LatencyModel::Jitter; + cfg.base_latency = std::chrono::milliseconds(5); + cfg.jitter = std::chrono::milliseconds(3); + sim::Simulator sim(cfg); + + for (int i = 0; i < 50; ++i) { + auto d = sim.simulated_latency_ms(); + REQUIRE(d >= std::chrono::milliseconds(5)); + REQUIRE(d <= std::chrono::milliseconds(8)); + } +} - ctrl.fault(ErrClosed); - ctrl.recover(); +// ── Watchdog wiring ─────────────────────────────────────────────────────────── - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(ctrl.send(Context::background(), cmd, resp)); -} +TEST_CASE("dispatch kicks a registered stream's watchdog on every accepted request", + "[sim][REQ-SIM-006]") { + sim::Simulator sim; + REQUIRE_FALSE(sim.server().advance_to_rcp_configured()); + sim.register_stream(/*stream_key=*/7); -// ── Subscribe ───────────────────────────────────────────────────────────────── + regmap::RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + request::RequestLedger ledger; -TEST_CASE("sim Controller::subscribe returns a channel", "[sim][REQ-SIM-006]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - cfg.status_interval = std::chrono::milliseconds(50); - sim::Controller ctrl(cfg); - - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - REQUIRE(ch != nullptr); - ch->close(); -} + std::vector events; + sim.watchdog().subscribe([&](const watchdog::HealthEvent& ev) { events.push_back(ev); }); -TEST_CASE("sim Controller publishes status on interval", "[sim][REQ-SIM-007]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - cfg.status_interval = std::chrono::milliseconds(20); - sim::Controller ctrl(cfg); + // Never kicked yet: nothing to report. + REQUIRE_FALSE(sim.poll_watchdog(7, cfg, ledger, 1'000)); + REQUIRE(events.empty()); - std::shared_ptr ch; - { auto ec = ctrl.subscribe(Context::background(), ch); (void)ec; } + auto req = gpio_read_request(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(sim.dispatch(7, 0, req, {}, resp, resp_payload, /*now_ms=*/1'000)); - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - auto st = ch->try_recv(); - REQUIRE(st.has_value()); - ch->close(); -} + // Kicked at 1'000ms: polling before the timeout elapses reports nothing... + REQUIRE_FALSE(sim.poll_watchdog(7, cfg, ledger, 1'050)); + REQUIRE(events.empty()); -// ── Close ───────────────────────────────────────────────────────────────────── + // ...but polling once rx_wd_timeout_interval has elapsed since the kick + // reports the overflow. + REQUIRE_FALSE(sim.poll_watchdog(7, cfg, ledger, 1'150)); + REQUIRE(events.size() == 1); + REQUIRE(events[0].overflowed); +} -TEST_CASE("sim Controller::close stops background threads", "[sim][REQ-SIM-008]") { - sim::Config cfg; - cfg.zone = Zone::FrontLeft; - cfg.status_interval = std::chrono::milliseconds(50); - sim::Controller ctrl(cfg); - REQUIRE_FALSE(ctrl.close()); +TEST_CASE("dispatch on an unregistered stream does not touch the watchdog manager", + "[sim][REQ-SIM-007]") { + sim::Simulator sim; + REQUIRE_FALSE(sim.server().advance_to_rcp_configured()); + REQUIRE_FALSE(sim.watchdog().is_registered(9)); + + auto req = gpio_read_request(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(sim.dispatch(9, 0, req, {}, resp, resp_payload, 0)); + REQUIRE_FALSE(sim.watchdog().is_registered(9)); } diff --git a/tests/test_tsn.cpp b/tests/test_tsn.cpp index 71c7a83..52df862 100644 --- a/tests/test_tsn.cpp +++ b/tests/test_tsn.cpp @@ -13,7 +13,7 @@ // priority→PCP mapping and the pass-through behaviour, which are portable. #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/tsn.hpp" using namespace rcp; @@ -50,7 +50,7 @@ TEST_CASE("tsn: send applies priority class then delegates to inner", // Capture the command type/priority the inner controller actually receives, // proving the wrapper forwards after applying the (platform-gated) PCP. Priority seen = Priority::Normal; - auto inner = std::make_shared( + auto inner = std::make_shared( Zone::FrontLeft, [&](const Command& c) { seen = c.priority; return Response{c.id, Zone::FrontLeft, ResponseStatus::OK, {}}; @@ -69,7 +69,7 @@ TEST_CASE("tsn: send applies priority class then delegates to inner", } TEST_CASE("tsn: subscribe delegates to inner", "[tsn][REQ-TSN-006]") { - auto inner = std::make_shared(Zone::Central); + auto inner = std::make_shared(Zone::Central); auto ctrl = tsn::new_controller(inner, -1, tsn::default_tsn_config()); std::shared_ptr ch; diff --git a/tests/test_zonegroup.cpp b/tests/test_zonegroup.cpp index 1839e37..af50724 100644 --- a/tests/test_zonegroup.cpp +++ b/tests/test_zonegroup.cpp @@ -6,7 +6,7 @@ // fusa:test REQ-ZG-006 #include -#include "rcp/mock.hpp" +#include "rcp/legacy_mock.hpp" #include "rcp/proxy.hpp" #include "rcp/zonegroup.hpp" @@ -26,7 +26,7 @@ TEST_CASE("zonegroup: ZoneGroup::rear() has 2 zones", "[zonegroup]") { } TEST_CASE("zonegroup: send_group dispatches to all zones", "[zonegroup]") { - mock::Registry reg; + legacy_mock::Registry reg; zonegroup::GroupRegistry gr(reg); @@ -39,7 +39,7 @@ TEST_CASE("zonegroup: send_group dispatches to all zones", "[zonegroup]") { } TEST_CASE("zonegroup: send_group returns results per zone", "[zonegroup]") { - mock::Registry reg; + legacy_mock::Registry reg; zonegroup::GroupRegistry gr(reg); Command cmd; @@ -52,7 +52,7 @@ TEST_CASE("zonegroup: send_group returns results per zone", "[zonegroup]") { } TEST_CASE("zonegroup: missing zone returns error in result", "[zonegroup]") { - mock::Registry reg; + legacy_mock::Registry reg; // Deregister all zones to create an empty registry auto ec = reg.close(); (void)ec; @@ -99,7 +99,7 @@ TEST_CASE("zonegroup: ZoneGroup is a copyable value type", "[zonegroup][REQ-ZG-0 TEST_CASE("zonegroup: send_group honours the caller context deadline", "[zonegroup][REQ-ZG-006]") { - mock::Registry reg; + legacy_mock::Registry reg; zonegroup::GroupRegistry gr(reg); Command cmd; cmd.type = CommandType::Get;