From 61409fc6d905429dee1e4337b46d386918db4e57 Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:40:08 -0700 Subject: [PATCH] =?UTF-8?q?rewrite(phase4):=20mock.hpp=20batch=20B=20?= =?UTF-8?q?=E2=80=94=20Table=2024=20response=20suppression=20+=20regmap/di?= =?UTF-8?q?scovery=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the v3.0.0 ground-up rewrite (cpp-RCP #129, ROADMAP.md Phase 17), branch rewrite/v3-from-c-rcp. Phase 4 batch B of a four-batch mock.hpp port, built on batch A's server::Endpoint admission wiring. ## Ported (c-RCP src/mock.c -> mock::Server) - Table 24 response suppression (src/mock.c:1716-1760, REQ-RMAP-048/049): new private static Server::suppress_response_per_stream_cfg(), resolving stream_id via regmap::request_stream_cfg::resolve_index() and zeroing out_resp/out_resp_payload when the resolved stream's rx_ack_stream_index (Acknowledge-classified responses) or rx_resp_stream_index (everything else) is 0. - Storage: Server::set_request_stream_cfg()/request_stream_cfg() and set_ep_id_map()/ep_id_map(), mirroring c-RCP's rcp_mock_server_set_request_stream_cfg()/_set_ep_id_map() (src/mock.c:444-460/565-580), bounds-checked against regmap::request_stream_cfg::kMaxEntries/regmap::ep_id_map::kMaxEntries, syncing svr_request_stream_cfg_capacity/svr_ep_bytebus_id_map_capacity (REQ-RMAP-034/037). - Discovery wiring (mock.h:501-529, src/mock.c:663-687, REQ-RMAP-066): discovery::DiscoveryClaim member, discovery_claim() getter, set_discovery_timeout_us() keeping it and regs_.svr_ep_cfg.svr_discovery_timeout in sync. Confirmed by direct source read that c-RCP's own mock.c never wires the claim into dispatch, so nothing beyond storage was added. ## dispatch_* changes Each of the ten operational dispatch_*() functions (gpio/spi/i2c/adc/ pwm_in/lin/can/uart/iseled/mdio) renamed to dispatch_*_inner() (unchanged batch-A body), with a new thin dispatch_*() wrapper calling _inner then suppress_response_per_stream_cfg() -- mirroring c-RCP's dispatch_plain()/dispatch_plain_inner() split, generalized per endpoint type. dispatch() gained a trailing, defaulted avtp::StreamId stream_id parameter threaded to all ten; the default resolves to "no stream configured," so no pre-existing call site's behavior changes. dispatch_ep0() left untouched (not one of the ten operational endpoints). ## Bug found and fixed make_initial_register_map()'s ten default ep_id_mapping rows all left request_stream_index at its struct default (0) -- TC18's own end-of-table sentinel -- which would make regmap::ep_id_map::effective_count() read the whole populated table as zero rows. Fixed to 1 (matching row_init_default()'s own power-on convention); also fixed svr_ep_bytebus_id_map_capacity to sync to 10. Neither was exercised by existing tests, so this is inert today but fixes future batch-C consumers (broadcast_safe_state, sequencer access control). ## Small addition to already-merged discovery.hpp Added DiscoveryClaim::set_timeout() -- batch B's set_discovery_timeout_us() needed a way to re-derive the claim's window from a new register value without resetting holder_/ claimed_at_ (c-RCP's own documented contract), and no such mutator existed. ## Verification Existing 60 test cases required no changes (default request_streams table is empty, so suppression is a no-op unless a test opts in). 11 new TEST_CASEs added: ep_id_mapping bug-fix assertions, bounds-check tests, 5 Table-24 suppression tests ported from c-RCP's tests/test_mock.c:1061-1204, one sweep across all ten endpoint types, 3 discovery-claim tests ported from tests/test_mock.c:1917-1980 (adapted to DiscoveryClaim's steady_clock-based API). include/rcp/mock.hpp: 1387 -> 1759 lines. tests/test_mock.cpp: 1285 -> 1561 lines, 60 -> 71 TEST_CASEs (583 assertions). include/rcp/ discovery.hpp: 818 -> 837 lines. Full tree rebuilds clean (0 errors, 0 warnings), 58/58 ctest suites pass (100%) -- independently re-verified from a fresh build, not just the porting agent's own report. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- include/rcp/discovery.hpp | 19 ++ include/rcp/mock.hpp | 396 ++++++++++++++++++++++++++++++++++++-- tests/test_mock.cpp | 276 ++++++++++++++++++++++++++ 3 files changed, 679 insertions(+), 12 deletions(-) diff --git a/include/rcp/discovery.hpp b/include/rcp/discovery.hpp index 7a4a04e..4ff438a 100644 --- a/include/rcp/discovery.hpp +++ b/include/rcp/discovery.hpp @@ -755,6 +755,25 @@ class DiscoveryClaim { // HW_UNCONFIGURED (lifecycle.hpp's own reset path). void release() noexcept { holder_.reset(); } + // set_timeout — Phase 4/Phase 17 batch B (cpp-RCP issue #129), added to + // close a gap this port's own constructor-only timeout left open: + // c-RCP's rcp_mock_server_set_discovery_timeout_us() (src/mock.c: + // 669-687) re-derives srv->discovery_claim.timeout_ms from a NEW + // svr_discovery_timeout register write at any time, explicitly WITHOUT + // resetting held/claimant/deadline state ("Does NOT reset + // srv->discovery_claim's own held/claimant/deadline_ms state -- an + // in-flight claim's own current deadline is unaffected by a + // timeout-VALUE change mid-claim; only the window a FUTURE grant + // computes uses the new value"). Before this method existed, this + // class's timeout_ was fixed for the object's whole lifetime (set once, + // at construction) -- there was no way for a caller (mock::Server's own + // set_discovery_timeout_us(), below) to honor that same "update the + // window, leave any live claim's own current deadline alone" contract + // without reconstructing (and thereby wrongly resetting) this whole + // object. Same reasoning applies here: only timeout_ changes; holder_/ + // claimed_at_ are left exactly as they are. + void set_timeout(std::chrono::milliseconds timeout) noexcept { timeout_ = timeout; } + private: bool lapsed(TimePoint now) const noexcept { return !holder_.has_value() || (now - claimed_at_) >= timeout_; diff --git a/include/rcp/mock.hpp b/include/rcp/mock.hpp index 56725ab..5c20c92 100644 --- a/include/rcp/mock.hpp +++ b/include/rcp/mock.hpp @@ -106,6 +106,7 @@ #include #include #include +#include #include #include #include @@ -121,6 +122,7 @@ #include #include +#include #include #include #include @@ -342,12 +344,78 @@ inline acf::AcfMessageInfo make_acknowledge_rejected_response(const acf::AcfMess // ep0_.check_read_access(), not by lifecycle state or admission at all), // matching c-RCP's own mock.c, which never registers EP0 as a // rcp_mock_endpoint_slot_t either. +// +// Phase 4/Phase 17 batch B (cpp-RCP issue #129) adds three more pieces on +// top of batch A's admission wiring: +// +// 1. TC18 Table 24 response/ack routing suppression (REQ-RMAP-048/049) +// -- ported from c-RCP's suppress_response_per_stream_cfg() +// (src/mock.c:1716-1742) wrapping dispatch_plain() around +// dispatch_plain_inner() (src/mock.c:1743-1760). This mock's own ten +// typed dispatch_*() functions each already play +// dispatch_plain_inner()'s own role (the single-endpoint handler +// body, with every one of admit_and_classify()'s own early exits and +// the handler's own success/error exits) -- batch B applies the +// identical wrap, once per endpoint type: each public dispatch_*() +// entry point below is now a thin wrapper calling its own renamed +// dispatch_*_inner() (batch A's unchanged handler body) and then +// suppress_response_per_stream_cfg() (below) on whatever response it +// produced, before returning it to dispatch()'s own caller. Requires +// a stream_id at the call site to resolve into a +// regmap::RequestStreamConfig row -- dispatch()'s own signature grows +// a trailing, defaulted `avtp::StreamId stream_id` parameter for this +// (default avtp::StreamId{} -- to_u64() == 0 -- matches an +// unconfigured stream, which +// regmap::request_stream_cfg::resolve_index() already treats as "no +// match", so no existing dispatch() caller's behavior changes unless +// it opts in to a real stream_id AND has configured a matching +// request_streams[] row via set_request_stream_cfg() below). +// dispatch_ep0() is NOT wrapped, matching this same class's own +// established "EP0 is not one of this file's ten operational +// endpoints" exclusion directly above. +// +// 2. request_streams/ep_id_mapping storage -- regmap::RegisterMap already +// carries both tables as plain public members (regs_.request_streams, +// regs_.ep_id_mapping); set_request_stream_cfg()/request_stream_cfg() +// and set_ep_id_map()/ep_id_map() below add the same bounds-checked +// wholesale-replace + capacity-register-sync convention c-RCP's own +// rcp_mock_server_set_request_stream_cfg()/_set_ep_id_map() (mock.h) +// already establish (REQ-RMAP-034/037), so item 1's suppression logic +// above has a real, test-settable table to resolve a stream_id +// against instead of only ever seeing an empty one. +// +// 3. Discovery-stream claim storage (REQ-RMAP-066) -- a +// discovery::DiscoveryClaim member plus discovery_claim()/ +// set_discovery_timeout_us(), mirroring c-RCP's own +// rcp_mock_server_discovery_claim()/_set_discovery_timeout_us() +// (mock.h:501-529, mock.c:663-687) exactly as thin as c-RCP's own +// mock.c keeps it: direct storage plus a timeout setter that keeps +// regs_.svr_ep_cfg.svr_discovery_timeout and the claim's own internal +// window in sync, and nothing more -- c-RCP's mock.c itself never +// calls into discovery.h's own claim/timeout logic from inside its +// dispatch path either (confirmed by direct source read), so this +// port does not invent a dispatch()-side discovery-request handler +// that c-RCP has no counterpart for. class Server final { public: Server() : lifecycle_(), regs_(make_initial_register_map()), - ep0_(regs_, lifecycle_) {} + ep0_(regs_, lifecycle_) { + // REQ-RMAP-066: route the power-on svr_discovery_timeout through + // the same setter every later change to it uses, rather than + // duplicating the sync here -- mirrors c-RCP's own + // rcp_mock_server_new() (src/mock.c:291-298), which does the + // identical single call for the identical reason ("srv-> + // discovery_claim is never left holding a timeout_ms out of sync + // with svr_ep_cfg's own current value"). A no-op in terms of the + // actual numeric window right now -- discovery::DiscoveryClaim's + // own kDefaultTimeout (20 ms) already equals + // regmap::SvrEpCfg::svr_discovery_timeout's own default (20000 + // us) -- but establishes the single source of truth for whenever + // either one changes. + set_discovery_timeout_us(regs_.svr_ep_cfg.svr_discovery_timeout); + } Server(const Server&) = delete; Server& operator=(const Server&) = delete; @@ -360,6 +428,66 @@ class Server final { regmap::Ep0& ep0() noexcept { return ep0_; } + // ── Table 24 request-stream config / EP-ID mapping storage (Phase 4/ + // Phase 17 batch B) ────────────────────────────────────────────────── + // set_request_stream_cfg/set_ep_id_map wholesale-replace their own + // table (regs_.request_streams/regs_.ep_id_mapping, both already + // plain public RegisterMap members -- see this class's own header + // comment, item 2), bounds-checked against each table's own + // regmap.hpp-defined kMaxEntries and syncing the matching Table 20 + // capacity register, mirroring c-RCP's own + // rcp_mock_server_set_request_stream_cfg() (mock.c:444-460, + // REQ-RMAP-034) and rcp_mock_server_set_ep_id_map() (mock.c:565-580, + // REQ-RMAP-037) exactly. Returns false (the table left unchanged) iff + // entries.size() exceeds that bound; true otherwise. The getters are + // a convenience mirroring registers().request_streams/.ep_id_mapping + // directly -- either spelling reaches the same storage. + bool set_request_stream_cfg(std::vector entries) noexcept { + if (entries.size() > regmap::request_stream_cfg::kMaxEntries) return false; + regs_.general.svr_request_stream_cfg_capacity = static_cast(entries.size()); + regs_.request_streams = std::move(entries); + return true; + } + const std::vector& request_stream_cfg() const noexcept { + return regs_.request_streams; + } + + bool set_ep_id_map(std::vector entries) noexcept { + if (entries.size() > regmap::ep_id_map::kMaxEntries) return false; + regs_.general.svr_ep_bytebus_id_map_capacity = static_cast(entries.size()); + regs_.ep_id_mapping = std::move(entries); + return true; + } + const std::vector& ep_id_map() const noexcept { + return regs_.ep_id_mapping; + } + + // ── Discovery-stream claim (REQ-RMAP-066, Phase 4/Phase 17 batch B) ─── + // discovery_claim gives direct, mutable access to this Server's own + // discovery::DiscoveryClaim -- the same "caller may freely set/consult + // it directly" convention c-RCP's own rcp_mock_server_discovery_claim() + // (mock.h:501-529) establishes for its own plain-struct + // rcp_discovery_claim_t*. Never a null reference. + discovery::DiscoveryClaim& discovery_claim() noexcept { return discovery_claim_; } + + // set_discovery_timeout_us sets regs_.svr_ep_cfg.svr_discovery_timeout + // (TC18's own wire register, microseconds) AND re-derives + // discovery_claim_'s own internal window from it (truncating + // microsecond division, matching every other us/ms boundary + // conversion in this codebase's own convention of never silently + // rounding up past a caller's own requested bound) -- mirrors c-RCP's + // rcp_mock_server_set_discovery_timeout_us() (mock.c:669-687) exactly, + // including its own explicit "does NOT reset held/claimant/deadline" + // guarantee: discovery::DiscoveryClaim::set_timeout() (discovery.hpp) + // only ever touches its own timeout_ field, never holder_/claimed_at_ + // -- an in-flight claim's own current deadline is unaffected by a + // timeout-VALUE change mid-claim; only the window a FUTURE grant + // computes uses the new value. + void set_discovery_timeout_us(uint16_t timeout_us) noexcept { + regs_.svr_ep_cfg.svr_discovery_timeout = timeout_us; + discovery_claim_.set_timeout(std::chrono::milliseconds(timeout_us / 1000u)); + } + gpio::GpioEndpoint& gpio() noexcept { return gpio_; } spi::SpiEndpoint& spi() noexcept { return spi_; } i2c::I2cEndpoint& i2c() noexcept { return i2c_; } @@ -655,22 +783,47 @@ class Server final { // (rsp == false, never true of a genuine response this codec builds) // and out_resp_payload left empty: a caller MUST check the returned // std::error_code before assuming there is anything to send. + // + // Phase 4/Phase 17 batch B: `stream_id` (default avtp::StreamId{}, + // to_u64() == 0) names which request stream this request arrived on — + // mirrors c-RCP's own rcp_mock_server_dispatch()'s own stream_id + // parameter (mock.h), reduced to just what Table 24 response + // suppression (REQ-RMAP-048/049) needs: each of the ten dispatch_*() + // calls below now threads it through to that endpoint type's own + // suppress_response_per_stream_cfg() tail call — see this class's own + // header comment, item 1, and suppress_response_per_stream_cfg()'s own + // doc comment for the full contract. The default leaves every existing + // caller's behavior unchanged: an unconfigured/default stream_id never + // resolves to a real regmap::request_streams[] row, so suppression is + // always a no-op unless a caller both passes a real stream_id AND has + // configured a matching row via set_request_stream_cfg() above. 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 { + std::vector& out_resp_payload, + avtp::StreamId stream_id = avtp::StreamId{}) 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); - if (req.byte_bus_id == kI2cByteBusId) return dispatch_i2c(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kAdcByteBusId) return dispatch_adc(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kPwmInByteBusId) return dispatch_pwm_in(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kLinByteBusId) return dispatch_lin(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kCanByteBusId) return dispatch_can(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kUartByteBusId) return dispatch_uart(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kIseledByteBusId) return dispatch_iseled(req, req_payload, out_resp, out_resp_payload); - if (req.byte_bus_id == kMdioByteBusId) return dispatch_mdio(req, req_payload, out_resp, out_resp_payload); + if (req.byte_bus_id == kGpioByteBusId) + return dispatch_gpio(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kSpiByteBusId) + return dispatch_spi(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kI2cByteBusId) + return dispatch_i2c(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kAdcByteBusId) + return dispatch_adc(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kPwmInByteBusId) + return dispatch_pwm_in(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kLinByteBusId) + return dispatch_lin(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kCanByteBusId) + return dispatch_can(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kUartByteBusId) + return dispatch_uart(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kIseledByteBusId) + return dispatch_iseled(req, req_payload, out_resp, out_resp_payload, stream_id); + if (req.byte_bus_id == kMdioByteBusId) + return dispatch_mdio(req, req_payload, out_resp, out_resp_payload, stream_id); return set_error_response(req, make_error_code(regmap::RegMapErrc::invalid_parameter), out_resp, out_resp_payload); @@ -844,6 +997,58 @@ class Server final { } } + // suppress_response_per_stream_cfg — TC18 §12.7.7 Table 24 + // (REQ-RMAP-048/049), ported from c-RCP's own identically-named + // function (src/mock.c:1716-1742). Table 24's own two per-request- + // stream routing pointers each carry a "0 means no X is to be sent" + // default — rx_ack_stream_index for an Acknowledge-classified response + // (acf::response_kind_of(out_resp) == ResponseKind::Acknowledge, which + // also covers the Acknowledge-REJECTED shape + // make_acknowledge_rejected_response() builds above, since evt[3:0] + // alone — not err — decides that classification, same as c-RCP's own + // rcp_acf_classify_response()), rx_resp_stream_index for every other + // response kind (Write/Read/Error). This mock owns no real + // multi-stream transport to actually DELIVER a response on a + // caller-chosen stream (the same "simulator, not a scheduler/ + // transport" boundary this class's own tick()/drain_one() doc + // comments already establish) — but it CAN, and now does, honor the + // "0 means send nothing at all" half of that rule, which needs no + // transport concept whatsoever: out_resp/out_resp_payload are simply + // reset to the identical "nothing to send" shape DispatchErrc's own + // evt[3]-less outcomes already use (rsp == false, payload empty) — + // see admit_and_classify()'s own doc comment for that shape's own + // contract. + // + // An unresolvable stream_id (regmap::request_stream_cfg:: + // resolve_index() returns 0 — no set_request_stream_cfg() entry names + // it) suppresses nothing, the same fail-toward-no-action disposition + // every resolve_index() call site in c-RCP's own mock.c already uses; + // a caller that never configured a request stream (including every + // dispatch() call site that leaves the new trailing stream_id + // parameter at its default) sees this mock's pre-existing, unaffected + // response behavior. `if (!out_resp.rsp) return;` mirrors c-RCP's own + // `if (out->data == NULL) return;` leading guard — nothing built, + // nothing to suppress. + static void suppress_response_per_stream_cfg(const regmap::RegisterMap& regs, + avtp::StreamId stream_id, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + if (!out_resp.rsp) return; + + const uint8_t stream_index = regmap::request_stream_cfg::resolve_index( + regs.request_streams.data(), regs.request_streams.size(), stream_id.to_u64()); + if (stream_index == 0) return; + + const regmap::RequestStreamConfig& cfg = regs.request_streams[stream_index - 1]; + const bool suppress = (acf::response_kind_of(out_resp) == acf::ResponseKind::Acknowledge) + ? (cfg.rx_ack_stream_index == 0) + : (cfg.rx_resp_stream_index == 0); + if (!suppress) return; + + out_resp = acf::AcfMessageInfo{}; + out_resp_payload.clear(); + } + static regmap::RegisterMap make_initial_register_map() { regmap::RegisterMap regs; regs.general.svr_ep_count = 10; @@ -861,6 +1066,33 @@ class Server final { {kIseledEndpointId, kIseledByteBusId}, {kMdioEndpointId, kMdioByteBusId}, }; + // Bug fix (Phase 4/Phase 17 batch B): every row above left its own + // trailing request_stream_index/crc_required fields at + // EpIdMappingEntry's own struct defaults (0/false) -- but 0 is + // REQ-RMAP-054's own defined END-OF-TABLE SENTINEL + // (regmap::ep_id_map::effective_count(), regmap.hpp), not "row 0's + // own request stream". Left uncorrected, effective_count() over + // this table would read the very first row's request_stream_index + // == 0 and report the WHOLE ten-row table as zero effective rows, + // even though every row here is a real, populated mapping. Set to + // 1 (the same power-on default regmap::ep_id_map::row_init_default() + // itself assigns, and the same value RequestStreamConfig:: + // rx_resp_stream_index's own struct default already uses for "the + // one pre-existing stream a freshly reset server can answer + // through") so this table renders as ten real, associated rows + // rather than an apparently-empty one -- inert today (nothing in + // this file yet consults request_stream_index, see this class's + // own header comment on this batch's own scope), but a genuine + // correctness fix for whichever later batch's REQ-SEQ-013/ + // REQ-E2E-029/030/045 logic (broadcast_safe_state and friends, + // c-RCP's own mock.c) is first to actually read it. + for (auto& entry : regs.ep_id_mapping) entry.request_stream_index = 1; + // REQ-RMAP-037: syncs the matching Table 20 capacity register to + // this table's own real length, the same convention + // set_ep_id_map() below establishes for a later wholesale + // replacement — previously left at GeneralMap's own zero default + // despite this constructor already populating ten real rows above. + regs.general.svr_ep_bytebus_id_map_capacity = 10; return regs; } @@ -886,7 +1118,20 @@ class Server final { return lifecycle_.state() == lifecycle::ServerState::RcpConfigured; } + // dispatch_gpio — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_gpio_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_gpio(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_gpio_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_gpio_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -921,7 +1166,20 @@ class Server final { // `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. + // dispatch_spi — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_spi_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_spi(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_spi_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_spi_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -951,7 +1209,20 @@ class Server final { // evt_row2_kind_of), which I2cEndpoint::handle_request checks before // ever touching `payload` as transfer data — see its own doc comment // for why a Reserved or ConfigWrite evt must never reach transfer(). + // dispatch_i2c — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_i2c_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_i2c(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_i2c_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_i2c_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -998,7 +1269,20 @@ class Server final { // that as an AdcErrc::no_signal *error* response instead, preserving // this dispatch path's own pre-existing "queue underrun is an error" // contract for set_adc_response()'s own callers/tests. + // dispatch_adc — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_adc_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_adc(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_adc_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_adc_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1072,7 +1356,20 @@ class Server final { // (wire error code PwmInNoSignal) if nothing has been recorded yet or // the signal was cleared. `payload` is unused: §13.7.6.3 states the // PWM_IN request itself carries no functional byte_msg_payload. + // dispatch_pwm_in — Phase 4/Phase 17 batch B: thin wrapper applying + // TC18 Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_pwm_in_inner()'s own unchanged (batch A) handler body — + // see suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_pwm_in(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_pwm_in_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_pwm_in_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1106,7 +1403,20 @@ class Server final { // evt_row2_kind_of), which LinEndpoint::handle_request checks before // ever touching `payload` as transfer data — see its own doc comment // for why a Reserved or ConfigWrite evt must never reach transfer(). + // dispatch_lin — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_lin_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_lin(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_lin_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_lin_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1148,7 +1458,20 @@ class Server final { // hook), so out_resp_payload stays empty and the response is a plain // Acknowledge/WriteResponse, mirroring dispatch_gpio's write-response // shape rather than dispatch_i2c/dispatch_lin's read-response shape. + // dispatch_can — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_can_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_can(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_can_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_can_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1204,7 +1527,20 @@ class Server final { // not enforced here — deliberately out of scope for this evt[2:0] // classification pass, same as handle_request's own comment already // flags. + // dispatch_uart — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_uart_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_uart(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_uart_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_uart_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1253,7 +1589,20 @@ class Server final { // hardware" pattern set_i2c_response/set_lin_response already establish // for their own bus-transfer models. Answered as a ReadResponse, // mirroring dispatch_i2c/dispatch_lin's read-response shape. + // dispatch_iseled — Phase 4/Phase 17 batch B: thin wrapper applying + // TC18 Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_iseled_inner()'s own unchanged (batch A) handler body — + // see suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_iseled(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_iseled_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_iseled_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1306,7 +1655,20 @@ class Server final { // doc comment for why a Reserved or ConfigWrite evt must never reach // it. No set_mdio_response() hook exists — see mdio()'s own comment // above for why MDIO's self-contained register model needs none. + // dispatch_mdio — Phase 4/Phase 17 batch B: thin wrapper applying TC18 + // Table 24 response suppression (REQ-RMAP-048/049) around + // dispatch_mdio_inner()'s own unchanged (batch A) handler body — see + // suppress_response_per_stream_cfg()'s own doc comment and this + // class's own header comment, item 1. std::error_code dispatch_mdio(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, + avtp::StreamId stream_id) noexcept { + std::error_code ec = dispatch_mdio_inner(req, payload, out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + + std::error_code dispatch_mdio_inner(const acf::AcfMessageInfo& req, const std::vector& payload, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { if (!operational_requests_allowed()) { @@ -1374,6 +1736,16 @@ class Server final { // 37's gPTP lock-established/lost trigger signals — see // notify_gptp_lock_state()'s own doc comment above. server::GptpTriggerState gptp_trigger_state_{}; + // REQ-RMAP-066 (Phase 4/Phase 17 batch B): this Server's own + // discovery-stream claim/timeout state -- see discovery_claim()'s and + // set_discovery_timeout_us()'s own doc comments above. Default- + // constructed to discovery::DiscoveryClaim::kDefaultTimeout (20 ms), + // then immediately re-synced from regs_.svr_ep_cfg.svr_discovery_ + // timeout by the constructor body above -- the same two-step + // "zero-init default, then one real sync call" pattern this class's + // own gptp_trigger_state_ and every RegisterMap table above already + // use for their own power-on state. + discovery::DiscoveryClaim discovery_claim_{}; std::array, spi::kMaxChannels> spi_poci_{}; std::vector i2c_response_{}; bool i2c_acked_ = true; diff --git a/tests/test_mock.cpp b/tests/test_mock.cpp index a4e628c..00d930f 100644 --- a/tests/test_mock.cpp +++ b/tests/test_mock.cpp @@ -78,6 +78,19 @@ TEST_CASE("Server starts HW_UNCONFIGURED with a ten-endpoint register map", REQUIRE(server.registers().ep_id_mapping[8].byte_bus_id == mock::kIseledByteBusId); REQUIRE(server.registers().ep_id_mapping[9].ep_id == mock::kMdioEndpointId); REQUIRE(server.registers().ep_id_mapping[9].byte_bus_id == mock::kMdioByteBusId); + + // Bug fix (Phase 4/Phase 17 batch B): every seeded row's own + // request_stream_index must be REQ-RMAP-054's power-on default (1), + // never left at EpIdMappingEntry's own struct default (0) -- 0 is the + // end-of-table sentinel regmap::ep_id_map::effective_count() reads, + // so leaving row 0 at 0 would report this whole ten-row table as + // zero effective rows. + for (const auto& entry : server.registers().ep_id_mapping) { + REQUIRE(entry.request_stream_index == 1); + } + REQUIRE(regmap::ep_id_map::effective_count(server.registers().ep_id_mapping.data(), + server.registers().ep_id_mapping.size()) == 10); + REQUIRE(server.registers().general.svr_ep_bytebus_id_map_capacity == 10); } TEST_CASE("advance_to_rcp_configured drives the lifecycle straight to RCP_CONFIGURED", @@ -1283,3 +1296,266 @@ TEST_CASE("tick() reports nothing due while gPTP time is unlocked, even past the REQUIRE_FALSE(server.tick(mock::kGpioByteBusId, ctx, due_frame)); REQUIRE(server.pending_count(mock::kGpioByteBusId) == 1); // still stored } + +// ── Request-stream-cfg / EP-ID-map storage (Phase 4/Phase 17 batch B) ──────── +// set_request_stream_cfg()/set_ep_id_map() mirror c-RCP's own +// rcp_mock_server_set_request_stream_cfg()/_set_ep_id_map() (mock.c: +// 444-460/565-580) bounds-checked wholesale-replace + capacity-register-sync +// convention. + +TEST_CASE("set_request_stream_cfg replaces the table wholesale and syncs " + "svr_request_stream_cfg_capacity, bounded by kMaxEntries", + "[mock][REQ-RMAP-034][REQ-RMAP-047]") { + mock::Server server; + REQUIRE(server.request_stream_cfg().empty()); + REQUIRE(server.registers().general.svr_request_stream_cfg_capacity == 0); + + std::vector cfg(2); + cfg[0].stream_id = avtp::StreamId::from_u64(0x1111); + cfg[1].stream_id = avtp::StreamId::from_u64(0x2222); + REQUIRE(server.set_request_stream_cfg(cfg)); + REQUIRE(server.request_stream_cfg().size() == 2); + REQUIRE(server.request_stream_cfg()[1].stream_id.to_u64() == 0x2222); + REQUIRE(server.registers().general.svr_request_stream_cfg_capacity == 2); + + // Over kMaxEntries: rejected, table left unchanged. + std::vector too_many(regmap::request_stream_cfg::kMaxEntries + 1); + REQUIRE_FALSE(server.set_request_stream_cfg(too_many)); + REQUIRE(server.request_stream_cfg().size() == 2); +} + +TEST_CASE("set_ep_id_map replaces the table wholesale and syncs " + "svr_ep_bytebus_id_map_capacity, bounded by kMaxEntries", + "[mock][REQ-RMAP-037][REQ-RMAP-052]") { + mock::Server server; + REQUIRE(server.ep_id_map().size() == 10); // batch A's own power-on default + + std::vector entries(1); + entries[0].ep_id = 7; + entries[0].byte_bus_id = 7; + entries[0].request_stream_index = 1; + REQUIRE(server.set_ep_id_map(entries)); + REQUIRE(server.ep_id_map().size() == 1); + REQUIRE(server.registers().ep_id_mapping[0].ep_id == 7); + REQUIRE(server.registers().general.svr_ep_bytebus_id_map_capacity == 1); + + std::vector too_many(regmap::ep_id_map::kMaxEntries + 1); + REQUIRE_FALSE(server.set_ep_id_map(too_many)); + REQUIRE(server.ep_id_map().size() == 1); // unchanged +} + +// ── Table 24 response/ack routing suppression (Phase 4/Phase 17 batch B, +// REQ-RMAP-048/049) ──────────────────────────────────────────────────────── +// Ported from c-RCP's tests/test_mock.c +// test_response_suppressed_when_rx_resp_stream_index_is_zero() / +// test_response_not_suppressed_when_rx_resp_stream_index_is_nonzero() / +// test_response_not_suppressed_for_unresolvable_stream() / +// test_acknowledge_suppressed_by_default_ack_stream_index() / +// test_acknowledge_not_suppressed_when_rx_ack_stream_index_is_nonzero() +// (tests/test_mock.c:1061-1204), reduced to real dispatch()-driven +// Read/Write/Acknowledge responses this mock's own typed endpoints +// already produce, rather than a synthetic handler double. + +namespace { +constexpr uint64_t kSuppressionStreamRaw = 0x0102030405060708ULL; +avtp::StreamId kSuppressionStream() { return avtp::StreamId::from_u64(kSuppressionStreamRaw); } +} // namespace + +TEST_CASE("A Read/WriteResponse is suppressed (no wire response at all) when the resolved " + "request stream's rx_resp_stream_index == 0", + "[mock][REQ-RMAP-049]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + regmap::RequestStreamConfig cfg; + cfg.stream_id = kSuppressionStream(); + cfg.rx_resp_stream_index = 0; // "no response is to be sent" + REQUIRE(server.set_request_stream_cfg({cfg})); + + auto req = standard_request(mock::kGpioByteBusId, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {}, resp, resp_payload, kSuppressionStream()); + + // The handler still ran (GPIO's own read state is unaffected by + // suppression) -- only the wire response is withheld. + REQUIRE_FALSE(ec); + REQUIRE_FALSE(resp.rsp); + REQUIRE(resp_payload.empty()); +} + +TEST_CASE("A ReadResponse is NOT suppressed when the resolved request stream's " + "rx_resp_stream_index is nonzero (its own power-on default, 1)", + "[mock][REQ-RMAP-049]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + regmap::RequestStreamConfig cfg; // rx_resp_stream_index defaults to 1 + cfg.stream_id = kSuppressionStream(); + REQUIRE(server.set_request_stream_cfg({cfg})); + + auto req = standard_request(mock::kGpioByteBusId, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {}, resp, resp_payload, kSuppressionStream()); + + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); +} + +TEST_CASE("A response is NOT suppressed for a stream_id with no configured " + "request-stream-cfg entry at all (unresolvable stream suppresses nothing)", + "[mock][REQ-RMAP-049]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + // Deliberately no set_request_stream_cfg() call. + + auto req = standard_request(mock::kGpioByteBusId, /*write=*/false); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {}, resp, resp_payload, kSuppressionStream()); + + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); +} + +TEST_CASE("An Acknowledge is suppressed by rx_ack_stream_index's own default (0), " + "independent of rx_resp_stream_index", + "[mock][REQ-RMAP-048]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.admission(mock::kGpioByteBusId)->set_enable(false); // disabled -> queues + acks + + regmap::RequestStreamConfig cfg; // rx_ack_stream_index defaults to 0 + cfg.stream_id = kSuppressionStream(); + REQUIRE(server.set_request_stream_cfg({cfg})); + + auto req = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Or), /*evt_ack=*/true); + auto payload = gpio::encode_gpio_payload(0x0000'000F); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, payload, resp, resp_payload, kSuppressionStream()); + + // Without suppression this would be a genuine Acknowledge (REQ-SRV-016, + // covered above) -- rx_ack_stream_index == 0 withholds it instead, but + // the request is still genuinely queued. + REQUIRE_FALSE(ec); + REQUIRE_FALSE(resp.rsp); + REQUIRE(resp_payload.empty()); + REQUIRE(server.admission(mock::kGpioByteBusId)->queue_len() == 1); +} + +TEST_CASE("An Acknowledge is NOT suppressed when rx_ack_stream_index is nonzero, EVEN with " + "rx_resp_stream_index explicitly 0 on the same stream (field separation)", + "[mock][REQ-RMAP-048]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.admission(mock::kGpioByteBusId)->set_enable(false); + + regmap::RequestStreamConfig cfg; + cfg.stream_id = kSuppressionStream(); + cfg.rx_ack_stream_index = 1; // "send it" + cfg.rx_resp_stream_index = 0; // must NOT apply to an Acknowledge + REQUIRE(server.set_request_stream_cfg({cfg})); + + auto req = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Or), /*evt_ack=*/true); + auto payload = gpio::encode_gpio_payload(0x0000'000F); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, payload, resp, resp_payload, kSuppressionStream()); + + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::Acknowledge); +} + +TEST_CASE("Every operational endpoint type's response is suppressible via Table 24 " + "(sweep of all ten dispatch_*() wrappers)", + "[mock][REQ-RMAP-049]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + regmap::RequestStreamConfig cfg; + cfg.stream_id = kSuppressionStream(); + cfg.rx_resp_stream_index = 0; + REQUIRE(server.set_request_stream_cfg({cfg})); + + for (avtp::ByteBusId bus_id : {mock::kGpioByteBusId, mock::kSpiByteBusId, mock::kI2cByteBusId, + mock::kAdcByteBusId, mock::kPwmInByteBusId, mock::kLinByteBusId, + mock::kCanByteBusId, mock::kUartByteBusId, mock::kIseledByteBusId, + mock::kMdioByteBusId}) { + auto req = standard_request(bus_id, /*write=*/true, /*evt_op=*/0); + acf::AcfMessageInfo resp; + std::vector resp_payload; + // Every one of these ten byte_bus_ids' own Plain (evt[2:0]==000b) + // request succeeds unconditionally against this fixture's own + // defaults (empty payload is valid for all ten write paths tested + // above), so every response reaching suppression is a genuine one. + (void)server.dispatch(0, req, {}, resp, resp_payload, kSuppressionStream()); + REQUIRE_FALSE(resp.rsp); + REQUIRE(resp_payload.empty()); + } +} + +// ── Discovery-stream claim (REQ-RMAP-066, Phase 4/Phase 17 batch B) ────────── +// Ported from c-RCP's tests/test_mock.c +// test_new_server_discovery_claim_starts_with_the_tc18_default_timeout() / +// test_set_discovery_timeout_us_syncs_svr_ep_cfg_and_claim() / +// test_discovery_claim_lifecycle_driven_by_configured_timeout() +// (tests/test_mock.c:1917-1980), adapted to discovery::DiscoveryClaim's own +// std::chrono::steady_clock::time_point-based API (discovery.hpp) rather than +// c-RCP's raw timeout_ms/now_ms integers -- behaviorally equivalent, since +// this class exposes no direct "current timeout" getter (matching c-RCP's own +// "thin storage, nothing more" scope for this batch -- see mock.hpp's own +// header comment). + +TEST_CASE("A new Server's discovery_claim starts unheld, with TC18's own default " + "svr_discovery_timeout (20000 us)", + "[mock][REQ-RMAP-066]") { + mock::Server server; + REQUIRE(server.registers().svr_ep_cfg.svr_discovery_timeout == 20000); + + const auto now = discovery::DiscoveryClaim::Clock::now(); + REQUIRE_FALSE(server.discovery_claim().has_active_claim(now)); +} + +TEST_CASE("set_discovery_timeout_us keeps svr_ep_cfg and discovery_claim's own window in sync", + "[mock][REQ-RMAP-066]") { + mock::Server server; + server.set_discovery_timeout_us(10000); // 10 ms, a short, test-friendly window + REQUIRE(server.registers().svr_ep_cfg.svr_discovery_timeout == 10000); + + const auto base = discovery::DiscoveryClaim::Clock::now(); + REQUIRE(server.discovery_claim().on_discovery_request( + /*client=*/1, lifecycle::ServerState::HwUnconfigured, base) == + discovery::DiscoveryClaim::ClaimOutcome::Claimed); + + // Well within the 10 ms window: still held. + REQUIRE(server.discovery_claim().has_active_claim(base + std::chrono::milliseconds(5))); + // The window has now lapsed: open again. + REQUIRE_FALSE(server.discovery_claim().has_active_claim(base + std::chrono::milliseconds(10))); +} + +TEST_CASE("discovery_claim()'s own real claim lifecycle (open -> claimed -> a second " + "requester refused -> lapse -> re-grantable) is genuinely driven by " + "set_discovery_timeout_us()'s configured window", + "[mock][REQ-RMAP-066][REQ-DISC-029]") { + mock::Server server; + server.set_discovery_timeout_us(10000); // 10 ms + + const auto base = discovery::DiscoveryClaim::Clock::now(); + auto& claim = server.discovery_claim(); + + REQUIRE(claim.on_discovery_request(/*client=*/1, lifecycle::ServerState::HwUnconfigured, base) == + discovery::DiscoveryClaim::ClaimOutcome::Claimed); + // A second, different requester within the window is refused + // (REQ-DISC-029), not granted. + REQUIRE(claim.on_discovery_request(/*client=*/2, lifecycle::ServerState::HwUnconfigured, + base + std::chrono::milliseconds(5)) == + discovery::DiscoveryClaim::ClaimOutcome::HeldByOther); + // Once the window has lapsed, the second requester is re-grantable. + REQUIRE(claim.on_discovery_request(/*client=*/2, lifecycle::ServerState::HwUnconfigured, + base + std::chrono::milliseconds(10)) == + discovery::DiscoveryClaim::ClaimOutcome::Claimed); +}