Skip to main content

rusty_stack/core/
validation.rs

1//! Validation state machine governing tier transitions and UI filtering.
2//!
3//! This module implements:
4//! - [`ValidationStateMachine`] — enforces legal transitions between validation
5//!   tiers (Validated ↔ Candidate, Candidate ↔ Experimental, any → Blocked).
6//! - [`ValidationEntry`] — pairs a component ID with its current tier, defaulting
7//!   to `Candidate` for new components.
8//! - [`TierFilter`] — determines visibility and preselection for UI rendering.
9//!
10//! # Legal Transitions
11//!
12//! ```text
13//! Validated  ←→  Candidate  ←→  Experimental
14//!     ↓             ↓               ↓
15//!           Blocked (terminal)
16//! ```
17//!
18//! Illegal transitions:
19//! - `Experimental → Validated` (must go through Candidate first)
20//! - `Blocked → anything` (Blocked is terminal)
21//!
22//! # UI Filtering Rules
23//!
24//! | Tier         | Visible | Preselected |
25//! |--------------|---------|-------------|
26//! | Validated    | Yes     | Yes         |
27//! | Candidate    | Yes     | No          |
28//! | Experimental | No*     | No          |
29//! | Blocked      | No      | No          |
30//!
31//! \* Experimental components are visible only with an explicit flag.
32
33use crate::core::types::ValidationTier;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37// ---------------------------------------------------------------------------
38// Transition Error
39// ---------------------------------------------------------------------------
40
41/// Error returned when an illegal tier transition is attempted.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TransitionError {
44    pub from: ValidationTier,
45    pub to: ValidationTier,
46    pub reason: String,
47}
48
49impl std::fmt::Display for TransitionError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(
52            f,
53            "illegal transition from {:?} to {:?}: {}",
54            self.from, self.to, self.reason
55        )
56    }
57}
58
59impl std::error::Error for TransitionError {}
60
61// ---------------------------------------------------------------------------
62// ValidationStateMachine
63// ---------------------------------------------------------------------------
64
65/// State machine governing transitions between validation tiers.
66///
67/// The machine enforces the following transition rules:
68/// - `Validated ↔ Candidate` — bidirectional
69/// - `Candidate ↔ Experimental` — bidirectional
70/// - `Any → Blocked` — one-way (terminal)
71/// - `Validated ↔ Validated` — self-transition (no-op, succeeds)
72/// - `Candidate ↔ Candidate` — self-transition (no-op, succeeds)
73/// - `Experimental ↔ Experimental` — self-transition (no-op, succeeds)
74/// - `Blocked ↔ Blocked` — self-transition (no-op, succeeds)
75///
76/// Illegal transitions return a [`TransitionError`].
77#[derive(Debug, Clone, Default)]
78pub struct ValidationStateMachine {
79    /// Map from component ID to its current validation tier.
80    entries: HashMap<String, ValidationTier>,
81}
82
83impl ValidationStateMachine {
84    /// Create a new empty state machine.
85    pub fn new() -> Self {
86        Self {
87            entries: HashMap::new(),
88        }
89    }
90
91    /// Register a new component. Defaults to [`ValidationTier::Candidate`].
92    ///
93    /// If the component already exists, this is a no-op (preserves existing tier).
94    pub fn register(&mut self, component_id: impl Into<String>) {
95        let id = component_id.into();
96        self.entries.entry(id).or_insert(ValidationTier::Candidate);
97    }
98
99    /// Register a component with an explicit initial tier.
100    ///
101    /// If the component already exists, this is a no-op.
102    pub fn register_with_tier(&mut self, component_id: impl Into<String>, tier: ValidationTier) {
103        let id = component_id.into();
104        self.entries.entry(id).or_insert(tier);
105    }
106
107    /// Attempt to transition a component to a new tier.
108    ///
109    /// Returns `Ok(())` if the transition is legal, `Err(TransitionError)` otherwise.
110    pub fn transition(
111        &mut self,
112        component_id: &str,
113        target: ValidationTier,
114    ) -> Result<(), TransitionError> {
115        let current = self
116            .entries
117            .get(component_id)
118            .copied()
119            .unwrap_or(ValidationTier::Candidate);
120
121        if Self::is_legal_transition(current, target) {
122            self.entries.insert(component_id.to_string(), target);
123            Ok(())
124        } else {
125            Err(TransitionError {
126                from: current,
127                to: target,
128                reason: Self::transition_reason(current, target),
129            })
130        }
131    }
132
133    /// Query the current tier of a component.
134    ///
135    /// Returns `ValidationTier::Candidate` for unregistered components.
136    pub fn tier(&self, component_id: &str) -> ValidationTier {
137        self.entries
138            .get(component_id)
139            .copied()
140            .unwrap_or(ValidationTier::Candidate)
141    }
142
143    /// Check if a transition is legal without performing it.
144    pub fn can_transition(&self, component_id: &str, target: ValidationTier) -> bool {
145        let current = self
146            .entries
147            .get(component_id)
148            .copied()
149            .unwrap_or(ValidationTier::Candidate);
150        Self::is_legal_transition(current, target)
151    }
152
153    /// Determine if a transition from `from` to `to` is legal.
154    ///
155    /// Legal transitions:
156    /// - Self-transitions (any → same): always legal
157    /// - Validated ↔ Candidate: bidirectional
158    /// - Candidate ↔ Experimental: bidirectional
159    /// - Any → Blocked: one-way
160    ///
161    /// Illegal:
162    /// - Experimental → Validated (must go through Candidate)
163    /// - Blocked → anything except Blocked (terminal state)
164    fn is_legal_transition(from: ValidationTier, to: ValidationTier) -> bool {
165        if from == to {
166            return true; // self-transitions
167        }
168        match from {
169            ValidationTier::Validated => {
170                matches!(to, ValidationTier::Candidate | ValidationTier::Blocked)
171            }
172            ValidationTier::Candidate => matches!(
173                to,
174                ValidationTier::Validated | ValidationTier::Experimental | ValidationTier::Blocked
175            ),
176            ValidationTier::Experimental => {
177                matches!(to, ValidationTier::Candidate | ValidationTier::Blocked)
178            }
179            ValidationTier::Blocked => false, // terminal: no outgoing transitions
180        }
181    }
182
183    /// Human-readable reason for why a transition is illegal.
184    fn transition_reason(from: ValidationTier, to: ValidationTier) -> String {
185        match from {
186            ValidationTier::Blocked => {
187                "Blocked is a terminal state; no transitions out allowed".to_string()
188            }
189            ValidationTier::Experimental if to == ValidationTier::Validated => {
190                "Experimental components must pass through Candidate before reaching Validated"
191                    .to_string()
192            }
193            _ => format!("transition from {:?} to {:?} is not allowed", from, to),
194        }
195    }
196
197    /// Get all registered component IDs and their tiers.
198    pub fn entries(&self) -> &HashMap<String, ValidationTier> {
199        &self.entries
200    }
201
202    /// Force-set a component's tier without transition checks.
203    ///
204    /// Used internally for loading persisted state or administrative overrides.
205    pub fn force_set(&mut self, component_id: impl Into<String>, tier: ValidationTier) {
206        self.entries.insert(component_id.into(), tier);
207    }
208}
209
210// ---------------------------------------------------------------------------
211// ValidationEntry — serializable component+tier pair
212// ---------------------------------------------------------------------------
213
214/// A component paired with its current validation tier.
215///
216/// New entries default to [`ValidationTier::Candidate`].
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct ValidationEntry {
219    pub component_id: String,
220    pub tier: ValidationTier,
221}
222
223impl ValidationEntry {
224    /// Create a new entry defaulting to Candidate tier.
225    pub fn new(component_id: impl Into<String>) -> Self {
226        Self {
227            component_id: component_id.into(),
228            tier: ValidationTier::Candidate,
229        }
230    }
231
232    /// Create a new entry with an explicit tier.
233    pub fn with_tier(component_id: impl Into<String>, tier: ValidationTier) -> Self {
234        Self {
235            component_id: component_id.into(),
236            tier,
237        }
238    }
239
240    /// Whether this component is visible in the default UI view.
241    pub fn is_visible(&self) -> bool {
242        self.tier.is_visible()
243    }
244
245    /// Whether this component is visible when `include_experimental` is set.
246    pub fn is_visible_with_experimental(&self) -> bool {
247        match self.tier {
248            ValidationTier::Validated
249            | ValidationTier::Candidate
250            | ValidationTier::Experimental => true,
251            ValidationTier::Blocked => false,
252        }
253    }
254
255    /// Whether this component should be preselected in the UI.
256    pub fn is_preselected(&self) -> bool {
257        self.tier.is_preselected()
258    }
259}
260
261// ---------------------------------------------------------------------------
262// TierFilter — batch filtering for UI rendering
263// ---------------------------------------------------------------------------
264
265/// Result of applying tier filtering to a list of entries.
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct FilteredEntries {
268    /// Entries visible in the default view, with preselection flags.
269    pub visible: Vec<VisibleEntry>,
270    /// Entries hidden from the default view (experimental).
271    pub hidden: Vec<ValidationEntry>,
272}
273
274/// A visible entry with its preselection state.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct VisibleEntry {
277    pub component_id: String,
278    pub tier: ValidationTier,
279    pub preselected: bool,
280}
281
282/// Apply tier filtering to a list of validation entries.
283///
284/// Returns a [`FilteredEntries`] struct partitioning entries into:
285/// - `visible`: entries that should be shown in the default UI view, with
286///   preselection flags set per the spec.
287/// - `hidden`: entries hidden from the default view (experimental).
288///
289/// Blocked entries are excluded entirely (never shown).
290pub fn filter_entries(entries: &[ValidationEntry]) -> FilteredEntries {
291    let mut visible = Vec::new();
292    let mut hidden = Vec::new();
293
294    for entry in entries {
295        match entry.tier {
296            ValidationTier::Validated => {
297                visible.push(VisibleEntry {
298                    component_id: entry.component_id.clone(),
299                    tier: entry.tier,
300                    preselected: true,
301                });
302            }
303            ValidationTier::Candidate => {
304                visible.push(VisibleEntry {
305                    component_id: entry.component_id.clone(),
306                    tier: entry.tier,
307                    preselected: false,
308                });
309            }
310            ValidationTier::Experimental => {
311                hidden.push(entry.clone());
312            }
313            ValidationTier::Blocked => {
314                // Never shown — silently excluded
315            }
316        }
317    }
318
319    FilteredEntries { visible, hidden }
320}
321
322/// Apply tier filtering with `include_experimental` flag.
323///
324/// When `include_experimental` is true, experimental entries are promoted
325/// to the visible list (but never preselected).
326pub fn filter_entries_with_experimental(
327    entries: &[ValidationEntry],
328    include_experimental: bool,
329) -> FilteredEntries {
330    if include_experimental {
331        let mut visible = Vec::new();
332
333        for entry in entries {
334            match entry.tier {
335                ValidationTier::Validated => {
336                    visible.push(VisibleEntry {
337                        component_id: entry.component_id.clone(),
338                        tier: entry.tier,
339                        preselected: true,
340                    });
341                }
342                ValidationTier::Candidate => {
343                    visible.push(VisibleEntry {
344                        component_id: entry.component_id.clone(),
345                        tier: entry.tier,
346                        preselected: false,
347                    });
348                }
349                ValidationTier::Experimental => {
350                    visible.push(VisibleEntry {
351                        component_id: entry.component_id.clone(),
352                        tier: entry.tier,
353                        preselected: false,
354                    });
355                }
356                ValidationTier::Blocked => {
357                    // Never shown
358                }
359            }
360        }
361
362        FilteredEntries {
363            visible,
364            hidden: vec![],
365        }
366    } else {
367        filter_entries(entries)
368    }
369}
370
371// ===========================================================================
372// Tests
373// ===========================================================================
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // =======================================================================
380    // VAL-CORE-011: Legal transitions
381    // =======================================================================
382
383    #[test]
384    fn test_validation_legal_transition_validated_to_candidate() {
385        let mut sm = ValidationStateMachine::new();
386        sm.register_with_tier("pytorch", ValidationTier::Validated);
387        assert!(sm.transition("pytorch", ValidationTier::Candidate).is_ok());
388        assert_eq!(sm.tier("pytorch"), ValidationTier::Candidate);
389    }
390
391    #[test]
392    fn test_validation_legal_transition_candidate_to_validated() {
393        let mut sm = ValidationStateMachine::new();
394        sm.register("pytorch"); // defaults to Candidate
395        assert!(sm.transition("pytorch", ValidationTier::Validated).is_ok());
396        assert_eq!(sm.tier("pytorch"), ValidationTier::Validated);
397    }
398
399    #[test]
400    fn test_validation_legal_transition_candidate_to_experimental() {
401        let mut sm = ValidationStateMachine::new();
402        sm.register("triton"); // defaults to Candidate
403        assert!(sm
404            .transition("triton", ValidationTier::Experimental)
405            .is_ok());
406        assert_eq!(sm.tier("triton"), ValidationTier::Experimental);
407    }
408
409    #[test]
410    fn test_validation_legal_transition_experimental_to_candidate() {
411        let mut sm = ValidationStateMachine::new();
412        sm.register_with_tier("triton", ValidationTier::Experimental);
413        assert!(sm.transition("triton", ValidationTier::Candidate).is_ok());
414        assert_eq!(sm.tier("triton"), ValidationTier::Candidate);
415    }
416
417    #[test]
418    fn test_validation_legal_transition_any_to_blocked() {
419        for from_tier in ValidationTier::all() {
420            let mut sm = ValidationStateMachine::new();
421            sm.register_with_tier("comp", *from_tier);
422            let result = sm.transition("comp", ValidationTier::Blocked);
423            assert!(
424                result.is_ok(),
425                "transition from {:?} to Blocked should be legal",
426                from_tier
427            );
428            assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
429        }
430    }
431
432    #[test]
433    fn test_validation_self_transitions_are_legal() {
434        for tier in ValidationTier::all() {
435            let mut sm = ValidationStateMachine::new();
436            sm.register_with_tier("comp", *tier);
437            let result = sm.transition("comp", *tier);
438            assert!(
439                result.is_ok(),
440                "self-transition for {:?} should be legal",
441                tier
442            );
443        }
444    }
445
446    // =======================================================================
447    // VAL-CORE-011: Illegal transitions
448    // =======================================================================
449
450    #[test]
451    fn test_validation_illegal_transition_experimental_to_validated() {
452        let mut sm = ValidationStateMachine::new();
453        sm.register_with_tier("triton", ValidationTier::Experimental);
454        let result = sm.transition("triton", ValidationTier::Validated);
455        assert!(
456            result.is_err(),
457            "Experimental → Validated should be illegal"
458        );
459        let err = result.unwrap_err();
460        assert_eq!(err.from, ValidationTier::Experimental);
461        assert_eq!(err.to, ValidationTier::Validated);
462        // Verify state was not changed
463        assert_eq!(sm.tier("triton"), ValidationTier::Experimental);
464    }
465
466    #[test]
467    fn test_validation_illegal_transition_blocked_to_validated() {
468        let mut sm = ValidationStateMachine::new();
469        sm.register_with_tier("comp", ValidationTier::Blocked);
470        let result = sm.transition("comp", ValidationTier::Validated);
471        assert!(result.is_err(), "Blocked → Validated should be illegal");
472        assert_eq!(result.unwrap_err().from, ValidationTier::Blocked);
473        assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
474    }
475
476    #[test]
477    fn test_validation_illegal_transition_blocked_to_candidate() {
478        let mut sm = ValidationStateMachine::new();
479        sm.register_with_tier("comp", ValidationTier::Blocked);
480        let result = sm.transition("comp", ValidationTier::Candidate);
481        assert!(result.is_err(), "Blocked → Candidate should be illegal");
482    }
483
484    #[test]
485    fn test_validation_illegal_transition_blocked_to_experimental() {
486        let mut sm = ValidationStateMachine::new();
487        sm.register_with_tier("comp", ValidationTier::Blocked);
488        let result = sm.transition("comp", ValidationTier::Experimental);
489        assert!(result.is_err(), "Blocked → Experimental should be illegal");
490    }
491
492    #[test]
493    fn test_validation_blocked_is_terminal() {
494        // Once blocked, no transition out should work
495        let mut sm = ValidationStateMachine::new();
496        sm.register_with_tier("comp", ValidationTier::Blocked);
497        for target in ValidationTier::all() {
498            if *target == ValidationTier::Blocked {
499                continue; // self-transition is ok
500            }
501            assert!(
502                !sm.can_transition("comp", *target),
503                "Blocked → {:?} should be illegal",
504                target
505            );
506        }
507    }
508
509    #[test]
510    fn test_validation_error_display() {
511        let err = TransitionError {
512            from: ValidationTier::Blocked,
513            to: ValidationTier::Validated,
514            reason: "Blocked is a terminal state".to_string(),
515        };
516        let msg = err.to_string();
517        assert!(msg.contains("Blocked"));
518        assert!(msg.contains("Validated"));
519    }
520
521    // =======================================================================
522    // VAL-CORE-012: Initial state is Candidate
523    // =======================================================================
524
525    #[test]
526    fn test_validation_new_component_defaults_to_candidate() {
527        let mut sm = ValidationStateMachine::new();
528        sm.register("new-component");
529        assert_eq!(sm.tier("new-component"), ValidationTier::Candidate);
530    }
531
532    #[test]
533    fn test_validation_unregistered_component_returns_candidate() {
534        let sm = ValidationStateMachine::new();
535        assert_eq!(sm.tier("nonexistent"), ValidationTier::Candidate);
536    }
537
538    #[test]
539    fn test_validation_entry_new_defaults_to_candidate() {
540        let entry = ValidationEntry::new("test-comp");
541        assert_eq!(entry.tier, ValidationTier::Candidate);
542        assert_eq!(entry.component_id, "test-comp");
543    }
544
545    #[test]
546    fn test_validation_register_existing_is_noop() {
547        let mut sm = ValidationStateMachine::new();
548        sm.register_with_tier("comp", ValidationTier::Validated);
549        sm.register("comp"); // should not overwrite
550        assert_eq!(sm.tier("comp"), ValidationTier::Validated);
551    }
552
553    // =======================================================================
554    // VAL-CORE-013: Tier filtering — UI visibility and preselection
555    // =======================================================================
556
557    #[test]
558    fn test_validation_filter_validated_visible_and_preselected() {
559        let entries = vec![ValidationEntry::with_tier(
560            "pytorch",
561            ValidationTier::Validated,
562        )];
563        let filtered = filter_entries(&entries);
564        assert_eq!(filtered.visible.len(), 1);
565        assert_eq!(filtered.hidden.len(), 0);
566        assert_eq!(filtered.visible[0].component_id, "pytorch");
567        assert!(filtered.visible[0].preselected);
568        assert_eq!(filtered.visible[0].tier, ValidationTier::Validated);
569    }
570
571    #[test]
572    fn test_validation_filter_candidate_visible_not_preselected() {
573        let entries = vec![ValidationEntry::with_tier(
574            "triton",
575            ValidationTier::Candidate,
576        )];
577        let filtered = filter_entries(&entries);
578        assert_eq!(filtered.visible.len(), 1);
579        assert_eq!(filtered.hidden.len(), 0);
580        assert!(!filtered.visible[0].preselected);
581    }
582
583    #[test]
584    fn test_validation_filter_experimental_hidden_from_default_view() {
585        let entries = vec![ValidationEntry::with_tier(
586            "deepspeed",
587            ValidationTier::Experimental,
588        )];
589        let filtered = filter_entries(&entries);
590        assert_eq!(filtered.visible.len(), 0);
591        assert_eq!(filtered.hidden.len(), 1);
592        assert_eq!(filtered.hidden[0].component_id, "deepspeed");
593    }
594
595    #[test]
596    fn test_validation_filter_blocked_never_shown() {
597        let entries = vec![ValidationEntry::with_tier(
598            "bad-comp",
599            ValidationTier::Blocked,
600        )];
601        let filtered = filter_entries(&entries);
602        assert_eq!(filtered.visible.len(), 0);
603        assert_eq!(filtered.hidden.len(), 0);
604    }
605
606    #[test]
607    fn test_validation_filter_all_tiers_combined() {
608        let entries = vec![
609            ValidationEntry::with_tier("rocm", ValidationTier::Validated),
610            ValidationEntry::with_tier("triton", ValidationTier::Candidate),
611            ValidationEntry::with_tier("deepspeed", ValidationTier::Experimental),
612            ValidationEntry::with_tier("bad-comp", ValidationTier::Blocked),
613        ];
614        let filtered = filter_entries(&entries);
615
616        // 2 visible (Validated + Candidate), 1 hidden (Experimental), 1 excluded (Blocked)
617        assert_eq!(filtered.visible.len(), 2);
618        assert_eq!(filtered.hidden.len(), 1);
619
620        // Check preselection
621        let rocm = filtered
622            .visible
623            .iter()
624            .find(|e| e.component_id == "rocm")
625            .unwrap();
626        assert!(rocm.preselected);
627        let triton = filtered
628            .visible
629            .iter()
630            .find(|e| e.component_id == "triton")
631            .unwrap();
632        assert!(!triton.preselected);
633    }
634
635    #[test]
636    fn test_validation_filter_include_experimental_flag() {
637        let entries = vec![
638            ValidationEntry::with_tier("rocm", ValidationTier::Validated),
639            ValidationEntry::with_tier("deepspeed", ValidationTier::Experimental),
640        ];
641
642        // Without flag: experimental is hidden
643        let filtered = filter_entries_with_experimental(&entries, false);
644        assert_eq!(filtered.visible.len(), 1);
645        assert_eq!(filtered.hidden.len(), 1);
646
647        // With flag: experimental is visible but not preselected
648        let filtered = filter_entries_with_experimental(&entries, true);
649        assert_eq!(filtered.visible.len(), 2);
650        assert_eq!(filtered.hidden.len(), 0);
651        let deepspeed = filtered
652            .visible
653            .iter()
654            .find(|e| e.component_id == "deepspeed")
655            .unwrap();
656        assert!(!deepspeed.preselected);
657    }
658
659    // =======================================================================
660    // Additional coverage
661    // =======================================================================
662
663    #[test]
664    fn test_validation_entry_visibility() {
665        let validated = ValidationEntry::with_tier("a", ValidationTier::Validated);
666        let candidate = ValidationEntry::with_tier("b", ValidationTier::Candidate);
667        let experimental = ValidationEntry::with_tier("c", ValidationTier::Experimental);
668        let blocked = ValidationEntry::with_tier("d", ValidationTier::Blocked);
669
670        assert!(validated.is_visible());
671        assert!(candidate.is_visible());
672        assert!(!experimental.is_visible());
673        assert!(!blocked.is_visible());
674
675        // With experimental flag
676        assert!(validated.is_visible_with_experimental());
677        assert!(candidate.is_visible_with_experimental());
678        assert!(experimental.is_visible_with_experimental());
679        assert!(!blocked.is_visible_with_experimental());
680    }
681
682    #[test]
683    fn test_validation_entry_preselection() {
684        let validated = ValidationEntry::with_tier("a", ValidationTier::Validated);
685        let candidate = ValidationEntry::with_tier("b", ValidationTier::Candidate);
686        let experimental = ValidationEntry::with_tier("c", ValidationTier::Experimental);
687        let blocked = ValidationEntry::with_tier("d", ValidationTier::Blocked);
688
689        assert!(validated.is_preselected());
690        assert!(!candidate.is_preselected());
691        assert!(!experimental.is_preselected());
692        assert!(!blocked.is_preselected());
693    }
694
695    #[test]
696    fn test_validation_entry_serde_roundtrip() {
697        let entry = ValidationEntry::with_tier("pytorch", ValidationTier::Validated);
698        let json = serde_json::to_string(&entry).unwrap();
699        let back: ValidationEntry = serde_json::from_str(&json).unwrap();
700        assert_eq!(entry, back);
701    }
702
703    #[test]
704    fn test_validation_can_transition_query() {
705        let mut sm = ValidationStateMachine::new();
706        sm.register_with_tier("comp", ValidationTier::Validated);
707        assert!(sm.can_transition("comp", ValidationTier::Candidate));
708        assert!(sm.can_transition("comp", ValidationTier::Blocked));
709        assert!(sm.can_transition("comp", ValidationTier::Validated)); // self
710        assert!(!sm.can_transition("comp", ValidationTier::Experimental));
711    }
712
713    #[test]
714    fn test_validation_force_set() {
715        let mut sm = ValidationStateMachine::new();
716        sm.register("comp");
717        // Force-set to Blocked (normally legal, but this bypasses checks)
718        sm.force_set("comp", ValidationTier::Blocked);
719        assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
720    }
721
722    #[test]
723    fn test_validation_entries_iterator() {
724        let mut sm = ValidationStateMachine::new();
725        sm.register("a");
726        sm.register_with_tier("b", ValidationTier::Validated);
727        assert_eq!(sm.entries().len(), 2);
728        assert_eq!(sm.entries().get("a"), Some(&ValidationTier::Candidate));
729        assert_eq!(sm.entries().get("b"), Some(&ValidationTier::Validated));
730    }
731
732    #[test]
733    fn test_validation_multi_step_transition() {
734        // Experimental → Candidate → Validated (legal path)
735        let mut sm = ValidationStateMachine::new();
736        sm.register_with_tier("comp", ValidationTier::Experimental);
737
738        // Step 1: Experimental → Candidate
739        assert!(sm.transition("comp", ValidationTier::Candidate).is_ok());
740        assert_eq!(sm.tier("comp"), ValidationTier::Candidate);
741
742        // Step 2: Candidate → Validated
743        assert!(sm.transition("comp", ValidationTier::Validated).is_ok());
744        assert_eq!(sm.tier("comp"), ValidationTier::Validated);
745    }
746
747    #[test]
748    fn test_validation_filtered_entries_serde_roundtrip() {
749        let filtered = FilteredEntries {
750            visible: vec![VisibleEntry {
751                component_id: "rocm".to_string(),
752                tier: ValidationTier::Validated,
753                preselected: true,
754            }],
755            hidden: vec![ValidationEntry::with_tier(
756                "exp",
757                ValidationTier::Experimental,
758            )],
759        };
760        let json = serde_json::to_string(&filtered).unwrap();
761        let back: FilteredEntries = serde_json::from_str(&json).unwrap();
762        assert_eq!(filtered, back);
763    }
764
765    #[test]
766    fn test_validation_transition_error_is_std_error() {
767        let err = TransitionError {
768            from: ValidationTier::Blocked,
769            to: ValidationTier::Validated,
770            reason: "test".to_string(),
771        };
772        let _: &dyn std::error::Error = &err; // verify trait object compat
773    }
774}