Skip to main content

mako_engine/
marktrolle.rs

1//! BDEW Rollenmodell — market-participant role configuration.
2//!
3//! The BDEW Rollenmodell für die Marktkommunikation (V2.2, January 2026) explicitly
4//! permits a single legal entity to hold multiple market roles simultaneously.
5//! Common combinations:
6//!
7//! | Combination | Regulatory basis |
8//! |---|---|
9//! | NB + gMSB | §41 MsbG — NB is grundzuständiger MSB for basic meters |
10//! | NB + BKV | Stadtwerke managing their own balance group |
11//! | NB + LF | Vertically integrated utility |
12//! | LF + BKV | Supplier managing its own balance group |
13//!
14//! ## Why role-awareness matters for PID routing
15//!
16//! Several EDIFACT PIDs are **shared across process families** and their correct
17//! inbound destination depends on which role this `makod` instance fills:
18//!
19//! | PID | ORDRSP semantics |
20//! |---|---|
21//! | 19001 (Bestellbestätigung) | → `gpke-konfiguration` when NB receiving from MSB |
22//! | 19001 (Bestellbestätigung) | → `wim-geraeteubernahme` when nMSB receiving from NB |
23//! | 19015 (Bestätigung Gerätewechselabsicht) | → `wim-geraeteubernahme` when NB receiving from nMSB |
24//! | 13003 (MSCONS Summenzeitreihe) | → `mabis-billing` when BKV receiving from BIKO |
25//! | 13003 (MSCONS Summenzeitreihe) | → MaBiS NZR handler when NB receiving from NB |
26//!
27//! By declaring which roles a `makod` instance serves, the engine can register
28//! only the PID routes that apply, preventing both silent dead-letters and
29//! accidental misrouting.
30//!
31//! ## Conflict guard
32//!
33//! [`PidRouter`] panics at build time if two modules register the same PID to
34//! **different** workflow names. Set explicit [`DeploymentRoles`] to exclude
35//! conflicting registrations from modules that don't apply to this instance.
36//!
37//! [`PidRouter`]: crate::pid_router::PidRouter
38
39use std::collections::HashSet;
40
41// ── Marktrolle ────────────────────────────────────────────────────────────────
42
43/// A BDEW market-participant role (Marktrolle).
44///
45/// Declares which roles this `makod` deployment fills within the German energy
46/// market communication (MaKo) ecosystem. A single deployment may hold several
47/// roles simultaneously (see module-level docs).
48///
49/// # Non-exhaustive
50///
51/// New roles may be added as BDEW regulations expand. Match with `_` in
52/// exhaustive arms or use [`DeploymentRoles::contains`] for membership checks.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Marktrolle {
56    /// Netzbetreiber (NB) — distribution/transmission network operator.
57    ///
58    /// Receives GPKE ANFRAGE messages (55001/55002/55017), issues ANTWORT
59    /// messages (55003–55006), runs GPKE Konfiguration (17134/17135 outbound
60    /// ORDERS, 19001/19002 inbound ORDRSP).
61    Nb,
62
63    /// Lieferant (LF) — energy supplier.
64    ///
65    /// Initiates GPKE Lieferbeginn/Lieferende, receives ANTWORT from NB.
66    /// Registers as inbound-ANTWORT recipient (55003–55006/55018) for the
67    /// LF-side anmeldung workflow.
68    Lf,
69
70    /// grundzuständiger Messstellenbetreiber (gMSB) — incumbent meter operator.
71    ///
72    /// Receives WiM UTILMD device-change messages (11001–11003). Often the same
73    /// legal entity as the NB (§41 MsbG).
74    Msb,
75
76    /// nicht-grundzuständiger Messstellenbetreiber (nMSB) — challenger meter operator.
77    ///
78    /// Sends WiM UTILMD device-change requests (11001) and WiM Geräteübernahme
79    /// ORDERS (17001, 17009). Receives inbound ORDRSP responses 19001/19002
80    /// (Bestellbestätigung/Ablehnung) and 19015/19016 (Gerätewechselabsicht).
81    Nmsb,
82
83    /// abgebender Messstellenbetreiber (aMSB) — outgoing meter operator.
84    ///
85    /// Receives WiM Abmeldung/Kündigung UTILMD (11002). This role is often
86    /// held by the gMSB after a successful nMSB takeover.
87    Amsb,
88
89    /// Bilanzkreisverantwortlicher (BKV) — balance responsible party.
90    ///
91    /// Receives MABIS billing MSCONS (PID 13003 from BIKO: Abrechnungssummenzeitreihe).
92    Bkv,
93
94    /// Übertragungsnetzbetreiber (ÜNB) — transmission system operator.
95    ///
96    /// Issues BG-SZR Kategorie B/C and BK-SZR Kategorie B/C MSCONS (PID 13003).
97    Uenb,
98
99    /// Bilanzkoordinator (BIKO) — balancing coordinator.
100    ///
101    /// Issues Abrechnungssummenzeitreihe MSCONS (PID 13003) to BKV and NB-DZR.
102    Biko,
103
104    /// Energieserviceanbieter (ESA) — energy service provider acting for the
105    /// Anschlussnutzer (PARTIN 37006, "Kommunikationsdaten des ESA Strom").
106    ///
107    /// **Strom only.** An ESA has no Zuordnung to a Marktlokation: its access to
108    /// values rests on the Anschlussnutzer's consent (§49 Abs. 2 Nr. 9 MsbG) and
109    /// a bilateral contract with the MSB, which §34 Abs. 2 S. 2 Nr. 10 MsbG makes
110    /// a mandatory, non-discriminatory Zusatzleistung.
111    ///
112    /// Sends REQOTE Anfrage, ORDERS 17007 (Bestellung/Abbestellung) and
113    /// ORDCHG 39002 (Stornierung); receives QUOTES 15003 and
114    /// ORDRSP 19011/19012/19013/19014, plus the values themselves.
115    ///
116    /// This role is for a deployment that **is** an ESA. An MSB *serving* an ESA
117    /// registers the inbound side under [`Marktrolle::Msb`].
118    Esa,
119
120    /// Gasnetzbetreiber (GNB) — gas network operator (GeLi Gas counterpart of NB).
121    ///
122    /// Receives GeLi Gas Lieferbeginn/Lieferende ANFRAGE messages (44001 ff.)
123    /// and issues the corresponding ANTWORT messages (44003–44006).
124    Gnb,
125
126    /// Lieferant Gas (LFG) — gas supplier (GeLi Gas counterpart of LF).
127    ///
128    /// Initiates GeLi Gas Lieferbeginn/Lieferende (44001/44002) and receives
129    /// the GNB's ANTWORT messages.
130    Lfg,
131
132    /// Lieferant neu (LFN) — the incoming supplier in a Lieferantenwechsel.
133    ///
134    /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
135    /// specific to the *gaining* side of a switch.
136    Lfn,
137
138    /// Lieferant alt (LFA) — the outgoing supplier in a Lieferantenwechsel.
139    ///
140    /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
141    /// specific to the *losing* side of a switch.
142    Lfa,
143
144    /// Marktgebietsverantwortlicher (MGV) — gas market-area manager.
145    ///
146    /// **Gas only.** Operates the Virtueller Handelspunkt and GaBi Gas
147    /// balancing (THE in Germany). Declares its communication data via
148    /// PARTIN 37011 ("Kommunikationsdaten des MGV Gas").
149    Mgv,
150}
151
152impl Marktrolle {
153    /// The canonical upper-case BDEW role code (e.g. `"NB"`, `"ÜNB"`, `"LFG"`).
154    #[must_use]
155    pub const fn as_code(self) -> &'static str {
156        match self {
157            Self::Nb => "NB",
158            Self::Lf => "LF",
159            Self::Msb => "MSB",
160            Self::Nmsb => "NMSB",
161            Self::Amsb => "AMSB",
162            Self::Bkv => "BKV",
163            Self::Uenb => "ÜNB",
164            Self::Biko => "BIKO",
165            Self::Esa => "ESA",
166            Self::Gnb => "GNB",
167            Self::Lfg => "LFG",
168            Self::Lfn => "LFN",
169            Self::Lfa => "LFA",
170            Self::Mgv => "MGV",
171        }
172    }
173
174    /// Parse a canonical upper-case BDEW role code back into a [`Marktrolle`].
175    ///
176    /// Round-trips [`as_code`] exactly (including the umlaut in `"ÜNB"`).
177    /// Returns `None` for anything else — callers decide whether an unknown
178    /// code is an error or simply "not one of ours".
179    ///
180    /// [`as_code`]: Marktrolle::as_code
181    #[must_use]
182    pub fn from_code(code: &str) -> Option<Self> {
183        Some(match code {
184            "NB" => Self::Nb,
185            "LF" => Self::Lf,
186            "MSB" => Self::Msb,
187            "NMSB" => Self::Nmsb,
188            "AMSB" => Self::Amsb,
189            "BKV" => Self::Bkv,
190            "ÜNB" => Self::Uenb,
191            "BIKO" => Self::Biko,
192            "ESA" => Self::Esa,
193            "GNB" => Self::Gnb,
194            "LFG" => Self::Lfg,
195            "LFN" => Self::Lfn,
196            "LFA" => Self::Lfa,
197            "MGV" => Self::Mgv,
198            _ => return None,
199        })
200    }
201
202    /// Map a PARTIN Prüfidentifikator to the sender's [`Marktrolle`].
203    ///
204    /// PARTIN (PIDs 37000–37014) distributes market-participant communication
205    /// data; the PID identifies the sender's role:
206    ///
207    /// | PID | Sender | `Marktrolle` |
208    /// |---|---|---|
209    /// | 37000 | LF Strom | [`Lf`](Self::Lf) |
210    /// | 37001 | NB Strom | [`Nb`](Self::Nb) |
211    /// | 37002 | MSB Strom | [`Msb`](Self::Msb) |
212    /// | 37003 | BKV Strom | [`Bkv`](Self::Bkv) |
213    /// | 37004 | BIKO Strom | [`Biko`](Self::Biko) |
214    /// | 37005 | ÜNB Strom | [`Uenb`](Self::Uenb) |
215    /// | 37006 | ESA Strom | [`Esa`](Self::Esa) |
216    /// | 37008 | LF Gas | [`Lfg`](Self::Lfg) |
217    /// | 37009 | NB Gas | [`Gnb`](Self::Gnb) |
218    /// | 37010 | MSB Gas | [`Msb`](Self::Msb) |
219    /// | 37011 | MGV Gas | [`Mgv`](Self::Mgv) |
220    /// | 37012 | NB Gas (spartenübergreifend) | [`Gnb`](Self::Gnb) |
221    /// | 37013 | MSB Gas (spartenübergreifend) | [`Msb`](Self::Msb) |
222    /// | 37014 | MSB Strom (spartenübergreifend) | [`Msb`](Self::Msb) |
223    ///
224    /// Returns `None` for unrecognised codes (37007 is a gap in the AHB).
225    #[must_use]
226    pub fn from_partin_pid(pid: u32) -> Option<Self> {
227        match pid {
228            37000 => Some(Self::Lf),
229            37001 => Some(Self::Nb),
230            37002 | 37010 | 37013 | 37014 => Some(Self::Msb),
231            37003 => Some(Self::Bkv),
232            37004 => Some(Self::Biko),
233            37005 => Some(Self::Uenb),
234            37006 => Some(Self::Esa),
235            37008 => Some(Self::Lfg),
236            37009 | 37012 => Some(Self::Gnb),
237            37011 => Some(Self::Mgv),
238            _ => None,
239        }
240    }
241}
242
243// Serde representation: the canonical BDEW role code (`"NB"`, `"ÜNB"`, `"LFG"`, …).
244// Used verbatim in persisted partner records and API payloads, so the wire
245// format matches EDIFACT/BO4E role codes exactly.
246impl serde::Serialize for Marktrolle {
247    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
248        serializer.serialize_str(self.as_code())
249    }
250}
251
252impl<'de> serde::Deserialize<'de> for Marktrolle {
253    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
254        let code = String::deserialize(deserializer)?;
255        Self::from_code(&code)
256            .ok_or_else(|| serde::de::Error::custom(format!("unknown Marktrolle code {code:?}")))
257    }
258}
259
260impl std::fmt::Display for Marktrolle {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.write_str(self.as_code())
263    }
264}
265
266// ── DeploymentRoles ───────────────────────────────────────────────────────────
267
268/// The set of [`Marktrolle`]s this `makod` deployment fills.
269///
270/// Used by [`EngineModule::register_pids_with_roles`] to conditionally register
271/// PID routes based on which roles are active. Modules check
272/// `roles.contains(Marktrolle::Nb)` before registering role-specific PIDs.
273///
274/// # Constructors
275///
276/// - [`DeploymentRoles::all()`] — registers everything regardless of role
277///   (useful for development and single-role deployments, default).
278/// - [`DeploymentRoles::from_roles`] — explicit set for multi-role conflict resolution.
279/// - Convenience methods: [`nb()`], [`lf()`], [`msb()`], [`nmsb()`] etc.
280///
281/// # Conflict guard
282///
283/// When two modules both register the same PID to **different** workflow names,
284/// `EngineBuilder::build` will detect the conflict and panic. Set exclusive roles
285/// to ensure only one workflow is registered per shared PID:
286///
287/// ```rust,ignore
288/// // NB deployment: GPKE registers 19001/19002 → gpke-konfiguration
289/// // nMSB deployment: WiM registers 19001/19002 → wim-geraeteubernahme
290/// // Combined (conflict!): set roles to prevent double-registration:
291/// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
292///
293/// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
294/// // Now only GPKE registers 19001/19002; WiM skips its nMSB-conditional block.
295/// ```
296///
297/// [`EngineModule::register_pids_with_roles`]: crate::builder::EngineModule::register_pids_with_roles
298/// [`nb()`]: DeploymentRoles::nb
299/// [`lf()`]: DeploymentRoles::lf
300/// [`msb()`]: DeploymentRoles::msb
301/// [`nmsb()`]: DeploymentRoles::nmsb
302#[derive(Debug, Clone)]
303pub struct DeploymentRoles {
304    /// When `true`, `contains()` returns `true` for every role (matches all).
305    all: bool,
306    roles: HashSet<Marktrolle>,
307}
308
309impl Default for DeploymentRoles {
310    /// Defaults to `all` — every role is considered active.
311    ///
312    /// This preserves backward-compatible behavior (all PIDs registered) for
313    /// deployments that have not yet configured explicit roles. Set explicit
314    /// roles via [`DeploymentRoles::from_roles`] for multi-role conflict safety.
315    fn default() -> Self {
316        Self::all()
317    }
318}
319
320impl DeploymentRoles {
321    /// All roles active — `contains` always returns `true`.
322    ///
323    /// The default for `EngineBuilder`. Modules register all their PIDs
324    /// unconditionally, identical to the pre-role-aware behavior.
325    ///
326    /// **Warning:** if two modules register the same PID to different workflows
327    /// and `all()` is active, the conflict guard in `PidRouter` will panic at
328    /// build time. Use [`from_roles`] to specify exactly which roles apply.
329    ///
330    /// [`from_roles`]: DeploymentRoles::from_roles
331    #[must_use]
332    pub fn all() -> Self {
333        Self {
334            all: true,
335            roles: HashSet::new(),
336        }
337    }
338
339    /// Construct from an explicit set of active roles.
340    ///
341    /// Only modules whose role-conditional PID blocks include at least one of
342    /// these roles will register those PIDs. All non-role-conditional PID blocks
343    /// (i.e., those that don't call `roles.contains(...)`) are always registered.
344    #[must_use]
345    pub fn from_roles(roles: impl IntoIterator<Item = Marktrolle>) -> Self {
346        Self {
347            all: false,
348            roles: roles.into_iter().collect(),
349        }
350    }
351
352    /// Return `true` when `role` is active.
353    ///
354    /// Always returns `true` for [`DeploymentRoles::all()`].
355    #[must_use]
356    pub fn contains(&self, role: Marktrolle) -> bool {
357        self.all || self.roles.contains(&role)
358    }
359
360    /// Return `true` when this is the [`all()`] sentinel (no explicit role list).
361    ///
362    /// [`all()`]: DeploymentRoles::all
363    #[must_use]
364    pub fn is_all(&self) -> bool {
365        self.all
366    }
367
368    // ── Convenience constructors ──────────────────────────────────────────────
369
370    /// NB-only deployment (most common for grid operators).
371    #[must_use]
372    pub fn nb() -> Self {
373        Self::from_roles([Marktrolle::Nb])
374    }
375
376    /// ESA-only deployment (energy service provider side).
377    #[must_use]
378    pub fn esa() -> Self {
379        Self::from_roles([Marktrolle::Esa])
380    }
381
382    /// LF-only deployment (supplier side).
383    #[must_use]
384    pub fn lf() -> Self {
385        Self::from_roles([Marktrolle::Lf])
386    }
387
388    /// gMSB-only deployment (incumbent meter operator).
389    #[must_use]
390    pub fn msb() -> Self {
391        Self::from_roles([Marktrolle::Msb])
392    }
393
394    /// nMSB-only deployment (challenger meter operator).
395    #[must_use]
396    pub fn nmsb() -> Self {
397        Self::from_roles([Marktrolle::Nmsb])
398    }
399
400    /// NB + gMSB (most common municipal utility / Stadtwerke combination).
401    #[must_use]
402    pub fn nb_msb() -> Self {
403        Self::from_roles([Marktrolle::Nb, Marktrolle::Msb])
404    }
405
406    /// NB + BKV (grid operator that also manages its own balance group).
407    #[must_use]
408    pub fn nb_bkv() -> Self {
409        Self::from_roles([Marktrolle::Nb, Marktrolle::Bkv])
410    }
411
412    /// Add a role to an existing set, returning a new `DeploymentRoles`.
413    #[must_use]
414    pub fn with(mut self, role: Marktrolle) -> Self {
415        if !self.all {
416            self.roles.insert(role);
417        }
418        self
419    }
420}
421
422impl FromIterator<Marktrolle> for DeploymentRoles {
423    fn from_iter<T: IntoIterator<Item = Marktrolle>>(iter: T) -> Self {
424        Self::from_roles(iter)
425    }
426}
427
428// ── Command licensing ─────────────────────────────────────────────────────────
429
430/// Why [`resolve_role`] rejected a command submission.
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum LicensingError {
433    /// The command permits several roles and the caller asserted none —
434    /// the engine cannot infer which hat the caller is wearing.
435    MarktrolleRequired,
436    /// The asserted role is not in the command's permitted set.
437    RoleNotPermitted,
438    /// The effective role is not among the deployment's configured roles.
439    RoleNotConfigured,
440}
441
442impl std::fmt::Display for LicensingError {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        match self {
445            Self::MarktrolleRequired => {
446                f.write_str("multi-role command requires an asserted Marktrolle")
447            }
448            Self::RoleNotPermitted => {
449                f.write_str("asserted Marktrolle is not permitted for this command")
450            }
451            Self::RoleNotConfigured => {
452                f.write_str("deployment is not configured for the required Marktrolle")
453            }
454        }
455    }
456}
457
458impl std::error::Error for LicensingError {}
459
460/// Resolve and validate the effective [`Marktrolle`] for a command submission.
461///
462/// Pure licensing policy — no registry lookup, no I/O:
463///
464/// - **Single-role commands** (`permitted.len() == 1`): the role is inferred
465///   from the permitted set; any `asserted` role is deliberately **ignored**
466///   so ERP connectors that always send a fixed role are not rejected.
467/// - **Multi-role commands** (`permitted.len() != 1`): `asserted` must be
468///   `Some` ([`LicensingError::MarktrolleRequired`]) and must be a member of
469///   `permitted` ([`LicensingError::RoleNotPermitted`]).
470///
471/// The effective role is then cross-checked against the deployment
472/// configuration: [`DeploymentRoles::all`] admits every role; an explicit
473/// (possibly empty) role set admits only its members
474/// ([`LicensingError::RoleNotConfigured`]).
475///
476/// # Errors
477///
478/// See [`LicensingError`] for the three rejection reasons.
479pub fn resolve_role(
480    permitted: &[Marktrolle],
481    asserted: Option<Marktrolle>,
482    configured: &DeploymentRoles,
483) -> Result<Marktrolle, LicensingError> {
484    let effective = if permitted.len() == 1 {
485        // Single-role command — fully implied; asserted role is ignored.
486        permitted[0]
487    } else {
488        let r = asserted.ok_or(LicensingError::MarktrolleRequired)?;
489        if !permitted.contains(&r) {
490            return Err(LicensingError::RoleNotPermitted);
491        }
492        r
493    };
494
495    if !configured.contains(effective) {
496        return Err(LicensingError::RoleNotConfigured);
497    }
498
499    Ok(effective)
500}
501
502#[cfg(test)]
503mod licensing_tests {
504    use super::*;
505
506    #[test]
507    fn code_round_trip_for_every_role() {
508        for role in [
509            Marktrolle::Nb,
510            Marktrolle::Lf,
511            Marktrolle::Msb,
512            Marktrolle::Nmsb,
513            Marktrolle::Amsb,
514            Marktrolle::Bkv,
515            Marktrolle::Uenb,
516            Marktrolle::Biko,
517            Marktrolle::Esa,
518            Marktrolle::Gnb,
519            Marktrolle::Lfg,
520            Marktrolle::Lfn,
521            Marktrolle::Lfa,
522            Marktrolle::Mgv,
523        ] {
524            assert_eq!(Marktrolle::from_code(role.as_code()), Some(role));
525        }
526        assert_eq!(Marktrolle::from_code("ÜNB"), Some(Marktrolle::Uenb));
527        assert_eq!(
528            Marktrolle::from_code("nb"),
529            None,
530            "codes are case-sensitive"
531        );
532        assert_eq!(Marktrolle::from_code(""), None);
533    }
534
535    #[test]
536    fn serde_round_trips_as_bdew_code() {
537        for role in [Marktrolle::Nb, Marktrolle::Uenb, Marktrolle::Lfg] {
538            let json = serde_json::to_string(&role).unwrap();
539            assert_eq!(json, format!("\"{}\"", role.as_code()));
540            let back: Marktrolle = serde_json::from_str(&json).unwrap();
541            assert_eq!(back, role);
542        }
543        assert!(serde_json::from_str::<Marktrolle>("\"LfStrom\"").is_err());
544    }
545
546    #[test]
547    fn from_partin_pid_covers_all_partin_pids() {
548        for pid in [
549            37000u32, 37001, 37002, 37003, 37004, 37005, 37006, 37008, 37009, 37010, 37011, 37012,
550            37013, 37014,
551        ] {
552            assert!(
553                Marktrolle::from_partin_pid(pid).is_some(),
554                "from_partin_pid({pid}) should return Some"
555            );
556        }
557        assert_eq!(Marktrolle::from_partin_pid(37000), Some(Marktrolle::Lf));
558        assert_eq!(Marktrolle::from_partin_pid(37008), Some(Marktrolle::Lfg));
559        assert_eq!(Marktrolle::from_partin_pid(37009), Some(Marktrolle::Gnb));
560        assert_eq!(Marktrolle::from_partin_pid(37011), Some(Marktrolle::Mgv));
561        assert_eq!(Marktrolle::from_partin_pid(37014), Some(Marktrolle::Msb));
562        // PID 37007 is not in the AHB (gap)
563        assert_eq!(Marktrolle::from_partin_pid(37007), None);
564        assert_eq!(Marktrolle::from_partin_pid(0), None);
565    }
566
567    #[test]
568    fn single_permitted_infers_and_ignores_assertion() {
569        let configured = DeploymentRoles::lf();
570        // No assertion → inferred.
571        assert_eq!(
572            resolve_role(&[Marktrolle::Lf], None, &configured),
573            Ok(Marktrolle::Lf)
574        );
575        // A wrong assertion is ignored, not rejected.
576        assert_eq!(
577            resolve_role(&[Marktrolle::Lf], Some(Marktrolle::Nb), &configured),
578            Ok(Marktrolle::Lf)
579        );
580    }
581
582    #[test]
583    fn multi_permitted_requires_assertion() {
584        let permitted = [Marktrolle::Nb, Marktrolle::Msb];
585        let configured = DeploymentRoles::nb_msb();
586        assert_eq!(
587            resolve_role(&permitted, None, &configured),
588            Err(LicensingError::MarktrolleRequired)
589        );
590        assert_eq!(
591            resolve_role(&permitted, Some(Marktrolle::Msb), &configured),
592            Ok(Marktrolle::Msb)
593        );
594    }
595
596    #[test]
597    fn multi_permitted_rejects_foreign_assertion() {
598        let permitted = [Marktrolle::Nb, Marktrolle::Msb];
599        let configured = DeploymentRoles::lf();
600        assert_eq!(
601            resolve_role(&permitted, Some(Marktrolle::Lf), &configured),
602            Err(LicensingError::RoleNotPermitted)
603        );
604    }
605
606    #[test]
607    fn configured_cross_check_rejects_unconfigured_role() {
608        // Resolves to LF; only NB is configured.
609        assert_eq!(
610            resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::nb()),
611            Err(LicensingError::RoleNotConfigured)
612        );
613        // Empty explicit set admits nothing.
614        assert_eq!(
615            resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::from_roles([])),
616            Err(LicensingError::RoleNotConfigured)
617        );
618    }
619
620    #[test]
621    fn deployment_roles_all_admits_every_role() {
622        assert_eq!(
623            resolve_role(&[Marktrolle::Biko], None, &DeploymentRoles::all()),
624            Ok(Marktrolle::Biko)
625        );
626        assert_eq!(
627            resolve_role(
628                &[Marktrolle::Bkv, Marktrolle::Uenb],
629                Some(Marktrolle::Uenb),
630                &DeploymentRoles::all()
631            ),
632            Ok(Marktrolle::Uenb)
633        );
634    }
635}
636
637#[cfg(test)]
638mod esa_role_tests {
639    use super::*;
640
641    /// An ESA-only deployment activates exactly that role.
642    #[test]
643    fn esa_is_a_selectable_deployment_role() {
644        let roles = DeploymentRoles::esa();
645        assert!(roles.contains(Marktrolle::Esa));
646        assert!(!roles.contains(Marktrolle::Msb));
647        assert!(!roles.is_all());
648    }
649
650    /// An integrated deployment can be both: the MSB serves ESAs and the ESA
651    /// arm consumes values. The two register disjoint PID sets.
652    #[test]
653    fn msb_and_esa_can_be_held_together() {
654        let roles = DeploymentRoles::from_roles([Marktrolle::Msb, Marktrolle::Esa]);
655        assert!(roles.contains(Marktrolle::Msb));
656        assert!(roles.contains(Marktrolle::Esa));
657    }
658}