Skip to main content

ftui_widgets/
stateful.rs

1//! Opt-in trait for widgets with persistable state.
2//!
3//! The [`Stateful`] trait defines a contract for widgets that can save and
4//! restore their state across sessions or configuration changes. It is
5//! orthogonal to [`StatefulWidget`](super::StatefulWidget) — a widget can
6//! implement both (render-time state mutation + persistence) or just one.
7//!
8//! # Design Invariants
9//!
10//! 1. **Round-trip fidelity**: `restore_state(save_state())` must produce an
11//!    equivalent observable state. Fields that are purely derived (e.g., cached
12//!    layout) may differ, but user-facing state (scroll position, selection,
13//!    expanded nodes) must survive the round trip.
14//!
15//! 2. **Graceful version mismatch**: When [`VersionedState`] detects a version
16//!    mismatch (`stored.version != T::state_version()`), the caller should fall
17//!    back to `T::State::default()` rather than panic. Migration logic belongs
18//!    in the downstream state migration system (bd-30g1.5).
19//!
20//! 3. **Key uniqueness**: Two distinct widget instances must produce distinct
21//!    [`StateKey`] values. The `(widget_type, instance_id)` pair is the primary
22//!    uniqueness invariant.
23//!
24//! 4. **No side effects**: `save_state` must be a pure read; `restore_state`
25//!    must only mutate `self` (no I/O, no global state).
26//!
27//! # Failure Modes
28//!
29//! | Failure | Cause | Fallback |
30//! |---------|-------|----------|
31//! | Deserialization error | Schema drift, corrupt data | Use `Default::default()` |
32//! | Version mismatch | Widget upgraded | Use `Default::default()` |
33//! | Missing state | First run, key changed | Use `Default::default()` |
34//! | Duplicate key | Bug in `state_key()` impl | Last-write-wins (logged) |
35//!
36//! # Feature Gate
37//!
38//! This module is always available, but the serde-based [`VersionedState`]
39//! wrapper requires the `state-persistence` feature for serialization support.
40
41use core::fmt;
42use core::hash::{Hash, Hasher};
43
44/// Unique identifier for a widget's persisted state.
45///
46/// A `StateKey` is the `(widget_type, instance_id)` pair that maps a widget
47/// instance to its stored state blob. Widget type is a `&'static str` (cheap
48/// to copy, no allocation) while instance id is an owned `String` to support
49/// dynamic widget trees.
50///
51/// # Construction
52///
53/// ```
54/// # use ftui_widgets::stateful::StateKey;
55/// // Explicit
56/// let key = StateKey::new("ScrollView", "main-content");
57///
58/// // From a widget-tree path
59/// let key = StateKey::from_path(&["app", "sidebar", "tree"]);
60/// assert_eq!(key.instance_id, "app/sidebar/tree");
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct StateKey {
64    /// The widget type name (e.g., `"ScrollView"`, `"TreeView"`).
65    pub widget_type: &'static str,
66    /// Instance-unique identifier within a widget tree.
67    pub instance_id: String,
68}
69
70impl StateKey {
71    /// Create a new state key from a widget type and instance id.
72    #[must_use]
73    pub fn new(widget_type: &'static str, id: impl Into<String>) -> Self {
74        Self {
75            widget_type,
76            instance_id: id.into(),
77        }
78    }
79
80    /// Build a state key from a path of widget-tree segments.
81    ///
82    /// Segments are joined with `/` to form the instance id.
83    /// The widget type is derived from the last segment.
84    ///
85    /// # Panics
86    ///
87    /// Panics if `path` is empty.
88    #[must_use]
89    pub fn from_path(path: &[&str]) -> Self {
90        assert!(
91            !path.is_empty(),
92            "StateKey::from_path requires a non-empty path"
93        );
94        let widget_type_str = path.last().expect("checked non-empty");
95        // We need a &'static str for widget_type. Since the caller passes &str
96        // slices that may or may not be 'static, we leak a copy. This is fine
97        // because state keys are created once and live for the program lifetime.
98        let widget_type: &'static str = Box::leak((*widget_type_str).to_owned().into_boxed_str());
99        Self {
100            widget_type,
101            instance_id: path.join("/"),
102        }
103    }
104
105    /// Canonical string representation: `"widget_type::instance_id"`.
106    #[must_use]
107    pub fn canonical(&self) -> String {
108        format!("{}::{}", self.widget_type, self.instance_id)
109    }
110}
111
112impl Hash for StateKey {
113    fn hash<H: Hasher>(&self, state: &mut H) {
114        self.widget_type.hash(state);
115        self.instance_id.hash(state);
116    }
117}
118
119impl fmt::Display for StateKey {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{}::{}", self.widget_type, self.instance_id)
122    }
123}
124
125/// Opt-in trait for widgets with persistable state.
126///
127/// Implementing this trait signals that a widget's user-facing state can be
128/// serialized, stored, and later restored. This is used by the state registry
129/// (bd-30g1.2) to persist widget state across sessions.
130///
131/// # Relationship to `StatefulWidget`
132///
133/// - [`StatefulWidget`](super::StatefulWidget): render-time mutable state (scroll clamping, layout cache).
134/// - [`Stateful`]: persistence contract (save/restore across sessions).
135///
136/// A widget can implement both when its render-time state is also worth persisting.
137///
138/// # Example
139///
140/// ```ignore
141/// use serde::{Serialize, Deserialize};
142/// use ftui_widgets::stateful::{Stateful, StateKey};
143///
144/// #[derive(Serialize, Deserialize, Default)]
145/// struct ScrollViewPersist {
146///     scroll_offset: u16,
147/// }
148///
149/// impl Stateful for ScrollView {
150///     type State = ScrollViewPersist;
151///
152///     fn state_key(&self) -> StateKey {
153///         StateKey::new("ScrollView", &self.id)
154///     }
155///
156///     fn save_state(&self) -> Self::State {
157///         ScrollViewPersist { scroll_offset: self.offset }
158///     }
159///
160///     fn restore_state(&mut self, state: Self::State) {
161///         self.offset = state.scroll_offset.min(self.max_offset());
162///     }
163/// }
164/// ```
165pub trait Stateful: Sized {
166    /// The state type that gets persisted.
167    ///
168    /// Must implement `Default` so missing/corrupt state degrades gracefully.
169    type State: Default;
170
171    /// Unique key identifying this widget instance.
172    ///
173    /// Two distinct widget instances **must** return distinct keys.
174    fn state_key(&self) -> StateKey;
175
176    /// Extract current state for persistence.
177    ///
178    /// This must be a pure read — no side effects, no I/O.
179    fn save_state(&self) -> Self::State;
180
181    /// Restore state from persistence.
182    ///
183    /// Implementations should clamp restored values to valid ranges
184    /// (e.g., scroll offset ≤ max offset) rather than trusting stored data.
185    fn restore_state(&mut self, state: Self::State);
186
187    /// State schema version for forward-compatible migrations.
188    ///
189    /// Bump this when the `State` type's serialized form changes in a
190    /// backwards-incompatible way. The state registry will discard stored
191    /// state with a mismatched version and fall back to `Default`.
192    fn state_version() -> u32 {
193        1
194    }
195}
196
197/// Version-tagged wrapper for serialized widget state.
198///
199/// When persisting state, the registry wraps the raw state in this envelope
200/// so it can detect schema version mismatches on restore.
201///
202/// # Serialization
203///
204/// With the `state-persistence` feature enabled, `VersionedState` derives
205/// `Serialize` and `Deserialize`. Without the feature, it is a plain struct
206/// usable for in-memory versioning.
207#[derive(Clone, Debug)]
208#[cfg_attr(
209    feature = "state-persistence",
210    derive(serde::Serialize, serde::Deserialize)
211)]
212pub struct VersionedState<S> {
213    /// Schema version (from `Stateful::state_version()`).
214    pub version: u32,
215    /// The actual state payload.
216    pub data: S,
217}
218
219impl<S> VersionedState<S> {
220    /// Wrap state with its current version tag.
221    #[must_use]
222    pub fn new(version: u32, data: S) -> Self {
223        Self { version, data }
224    }
225
226    /// Pack a widget's state into a versioned envelope.
227    pub fn pack<W: Stateful<State = S>>(widget: &W) -> Self {
228        Self {
229            version: W::state_version(),
230            data: widget.save_state(),
231        }
232    }
233
234    /// Attempt to unpack, returning `None` if the version does not match
235    /// the widget's current `state_version()`.
236    pub fn unpack<W: Stateful<State = S>>(self) -> Option<S> {
237        if self.version == W::state_version() {
238            Some(self.data)
239        } else {
240            None
241        }
242    }
243
244    /// Unpack with fallback: returns the stored data if versions match,
245    /// otherwise returns `S::default()`.
246    pub fn unpack_or_default<W: Stateful<State = S>>(self) -> S
247    where
248        S: Default,
249    {
250        if self.version == W::state_version() {
251            self.data
252        } else {
253            S::default()
254        }
255    }
256}
257
258impl<S: Default> Default for VersionedState<S> {
259    fn default() -> Self {
260        Self {
261            version: 1,
262            data: S::default(),
263        }
264    }
265}
266
267// ============================================================================
268// State Migration System (bd-30g1.5)
269// ============================================================================
270
271/// Error that can occur during state migration.
272#[derive(Debug, Clone)]
273pub enum MigrationError {
274    /// No migration path exists from source to target version.
275    NoPathFound { from: u32, to: u32 },
276    /// A migration function returned an error.
277    MigrationFailed { from: u32, to: u32, message: String },
278    /// Version numbers are invalid (e.g., target < source).
279    InvalidVersionRange { from: u32, to: u32 },
280}
281
282impl core::fmt::Display for MigrationError {
283    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
284        match self {
285            Self::NoPathFound { from, to } => {
286                write!(f, "no migration path from version {} to {}", from, to)
287            }
288            Self::MigrationFailed { from, to, message } => {
289                write!(f, "migration from {} to {} failed: {}", from, to, message)
290            }
291            Self::InvalidVersionRange { from, to } => {
292                write!(f, "invalid version range: {} to {}", from, to)
293            }
294        }
295    }
296}
297
298/// A single-step migration from version N to version N+1.
299///
300/// Migrations are always forward-only and increment by exactly one version.
301/// This ensures a clear, auditable upgrade path.
302///
303/// # Example
304///
305/// ```ignore
306/// // Migration from v1 ScrollState to v2 ScrollState (adds new field)
307/// struct ScrollStateV1ToV2;
308///
309/// impl StateMigration for ScrollStateV1ToV2 {
310///     type OldState = ScrollStateV1;
311///     type NewState = ScrollStateV2;
312///
313///     fn from_version(&self) -> u32 { 1 }
314///     fn to_version(&self) -> u32 { 2 }
315///
316///     fn migrate(&self, old: ScrollStateV1) -> Result<ScrollStateV2, String> {
317///         Ok(ScrollStateV2 {
318///             scroll_offset: old.scroll_offset,
319///             scroll_velocity: 0.0, // New field, default value
320///         })
321///     }
322/// }
323/// ```
324#[allow(clippy::wrong_self_convention)]
325pub trait StateMigration {
326    /// The state type before migration.
327    type OldState;
328    /// The state type after migration.
329    type NewState;
330
331    /// Source version this migration transforms from.
332    fn from_version(&self) -> u32;
333
334    /// Target version this migration produces.
335    /// Must equal `from_version() + 1`.
336    fn to_version(&self) -> u32;
337
338    /// Perform the migration.
339    ///
340    /// Returns `Err` with a message if the migration cannot be performed.
341    fn migrate(&self, old: Self::OldState) -> Result<Self::NewState, String>;
342}
343
344/// A type-erased migration step for use in migration chains.
345///
346/// This allows storing migrations with different types in a single collection.
347#[allow(clippy::wrong_self_convention)]
348pub trait ErasedMigration<S>: Send + Sync {
349    /// Source version.
350    fn from_version(&self) -> u32;
351    /// Target version.
352    fn to_version(&self) -> u32;
353    /// Perform migration on boxed state, returning boxed result.
354    fn migrate_erased(
355        &self,
356        old: Box<dyn core::any::Any + Send>,
357    ) -> Result<Box<dyn core::any::Any + Send>, String>;
358}
359
360/// A chain of migrations that can upgrade state through multiple versions.
361///
362/// # Design
363///
364/// The migration chain executes migrations in sequence, starting from the
365/// stored version and ending at the current version. Each step increments
366/// the version by exactly one.
367///
368/// # Example
369///
370/// ```ignore
371/// let mut chain = MigrationChain::<FinalState>::new();
372/// chain.register(Box::new(V1ToV2Migration));
373/// chain.register(Box::new(V2ToV3Migration));
374///
375/// // Migrate from v1 to v3 (current)
376/// let result = chain.migrate_to_current(v1_state, 1, 3);
377/// ```
378pub struct MigrationChain<S> {
379    /// Migrations indexed by their `from_version`.
380    migrations: std::collections::HashMap<u32, Box<dyn ErasedMigration<S>>>,
381}
382
383impl<S: 'static> MigrationChain<S> {
384    /// Create an empty migration chain.
385    #[must_use]
386    pub fn new() -> Self {
387        Self {
388            migrations: std::collections::HashMap::new(),
389        }
390    }
391
392    /// Register a migration step.
393    ///
394    /// # Panics
395    ///
396    /// Panics if a migration for the same `from_version` is already registered.
397    pub fn register(&mut self, migration: Box<dyn ErasedMigration<S>>) {
398        let from = migration.from_version();
399        let to = migration.to_version();
400        assert_eq!(
401            to,
402            from + 1,
403            "migration must increment version by exactly 1 (got {} -> {})",
404            from,
405            to
406        );
407        assert!(
408            !self.migrations.contains_key(&from),
409            "migration for version {} already registered",
410            from
411        );
412        self.migrations.insert(from, migration);
413    }
414
415    /// Check if a migration path exists from `from_version` to `to_version`.
416    #[must_use]
417    pub fn has_path(&self, from_version: u32, to_version: u32) -> bool {
418        if from_version >= to_version {
419            return from_version == to_version;
420        }
421        let mut current = from_version;
422        while current < to_version {
423            if !self.migrations.contains_key(&current) {
424                return false;
425            }
426            current += 1;
427        }
428        true
429    }
430
431    /// Attempt to migrate state from `from_version` to `to_version`.
432    ///
433    /// Returns `Ok(migrated_state)` on success, or `Err` if migration fails.
434    pub fn migrate(
435        &self,
436        state: Box<dyn core::any::Any + Send>,
437        from_version: u32,
438        to_version: u32,
439    ) -> Result<Box<dyn core::any::Any + Send>, MigrationError> {
440        if from_version > to_version {
441            return Err(MigrationError::InvalidVersionRange {
442                from: from_version,
443                to: to_version,
444            });
445        }
446        if from_version == to_version {
447            return Ok(state);
448        }
449
450        let mut current_state = state;
451        let mut current_version = from_version;
452
453        while current_version < to_version {
454            let migration =
455                self.migrations
456                    .get(&current_version)
457                    .ok_or(MigrationError::NoPathFound {
458                        from: current_version,
459                        to: to_version,
460                    })?;
461
462            current_state = migration.migrate_erased(current_state).map_err(|msg| {
463                MigrationError::MigrationFailed {
464                    from: current_version,
465                    to: current_version + 1,
466                    message: msg,
467                }
468            })?;
469
470            current_version += 1;
471        }
472
473        Ok(current_state)
474    }
475}
476
477impl<S: 'static> Default for MigrationChain<S> {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483/// Result of attempting state restoration with migration.
484#[derive(Debug)]
485pub enum RestoreResult<S> {
486    /// State was restored directly (versions matched).
487    Direct(S),
488    /// State was successfully migrated from an older version.
489    Migrated { state: S, from_version: u32 },
490    /// Migration failed; falling back to default state.
491    DefaultFallback { error: MigrationError, default: S },
492}
493
494impl<S> RestoreResult<S> {
495    /// Extract the state value, regardless of how it was obtained.
496    pub fn into_state(self) -> S {
497        match self {
498            Self::Direct(s) | Self::Migrated { state: s, .. } => s,
499            Self::DefaultFallback { default, .. } => default,
500        }
501    }
502
503    /// Returns `true` if the state was migrated.
504    #[must_use]
505    pub fn was_migrated(&self) -> bool {
506        matches!(self, Self::Migrated { .. })
507    }
508
509    /// Returns `true` if we fell back to default.
510    #[must_use]
511    pub fn is_fallback(&self) -> bool {
512        matches!(self, Self::DefaultFallback { .. })
513    }
514}
515
516impl<S> VersionedState<S> {
517    /// Attempt to unpack with migration support.
518    ///
519    /// If the stored version doesn't match the current version, attempts to
520    /// migrate through the provided chain. Falls back to default on failure.
521    ///
522    /// # Type Parameters
523    ///
524    /// - `W`: The widget type that implements `Stateful<State = S>`.
525    ///
526    /// # Example
527    ///
528    /// ```ignore
529    /// let chain = MigrationChain::new();
530    /// // ... register migrations ...
531    ///
532    /// let versioned = load_state_from_disk();
533    /// let result = versioned.unpack_with_migration::<MyWidget>(&chain);
534    /// let state = result.into_state();
535    /// ```
536    pub fn unpack_with_migration<W>(self, chain: &MigrationChain<S>) -> RestoreResult<S>
537    where
538        W: Stateful<State = S>,
539        S: Default + 'static + Send,
540    {
541        let current_version = W::state_version();
542
543        if self.version == current_version {
544            return RestoreResult::Direct(self.data);
545        }
546
547        // Try migration
548        let boxed: Box<dyn core::any::Any + Send> = Box::new(self.data);
549        match chain.migrate(boxed, self.version, current_version) {
550            Ok(migrated) => {
551                if let Ok(state) = migrated.downcast::<S>() {
552                    RestoreResult::Migrated {
553                        state: *state,
554                        from_version: self.version,
555                    }
556                } else {
557                    // Type mismatch after migration (shouldn't happen with correct chain)
558                    RestoreResult::DefaultFallback {
559                        error: MigrationError::MigrationFailed {
560                            from: self.version,
561                            to: current_version,
562                            message: "type mismatch after migration".to_string(),
563                        },
564                        default: S::default(),
565                    }
566                }
567            }
568            Err(e) => RestoreResult::DefaultFallback {
569                error: e,
570                default: S::default(),
571            },
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    // ── Test widget ─────────────────────────────────────────────────
581
582    #[derive(Default)]
583    struct TestScrollView {
584        id: String,
585        offset: u16,
586        max: u16,
587    }
588
589    #[derive(Clone, Debug, Default, PartialEq)]
590    struct ScrollState {
591        scroll_offset: u16,
592    }
593
594    impl Stateful for TestScrollView {
595        type State = ScrollState;
596
597        fn state_key(&self) -> StateKey {
598            StateKey::new("ScrollView", &self.id)
599        }
600
601        fn save_state(&self) -> ScrollState {
602            ScrollState {
603                scroll_offset: self.offset,
604            }
605        }
606
607        fn restore_state(&mut self, state: ScrollState) {
608            self.offset = state.scroll_offset.min(self.max);
609        }
610    }
611
612    // ── Another test widget with version 2 ──────────────────────────
613
614    #[derive(Default)]
615    struct TestTreeView {
616        id: String,
617        expanded: Vec<u32>,
618    }
619
620    #[derive(Clone, Debug, Default, PartialEq)]
621    struct TreeState {
622        expanded_nodes: Vec<u32>,
623        collapse_all_on_blur: bool, // added in v2
624    }
625
626    impl Stateful for TestTreeView {
627        type State = TreeState;
628
629        fn state_key(&self) -> StateKey {
630            StateKey::new("TreeView", &self.id)
631        }
632
633        fn save_state(&self) -> TreeState {
634            TreeState {
635                expanded_nodes: self.expanded.clone(),
636                collapse_all_on_blur: false,
637            }
638        }
639
640        fn restore_state(&mut self, state: TreeState) {
641            self.expanded = state.expanded_nodes;
642        }
643
644        fn state_version() -> u32 {
645            2
646        }
647    }
648
649    // ── StateKey tests ──────────────────────────────────────────────
650
651    #[test]
652    fn state_key_new() {
653        let key = StateKey::new("ScrollView", "main");
654        assert_eq!(key.widget_type, "ScrollView");
655        assert_eq!(key.instance_id, "main");
656    }
657
658    #[test]
659    fn state_key_from_path() {
660        let key = StateKey::from_path(&["app", "sidebar", "tree"]);
661        assert_eq!(key.instance_id, "app/sidebar/tree");
662        assert_eq!(key.widget_type, "tree");
663    }
664
665    #[test]
666    #[should_panic(expected = "non-empty path")]
667    fn state_key_from_empty_path_panics() {
668        let _ = StateKey::from_path(&[]);
669    }
670
671    #[test]
672    fn state_key_uniqueness() {
673        let a = StateKey::new("ScrollView", "main");
674        let b = StateKey::new("ScrollView", "sidebar");
675        let c = StateKey::new("TreeView", "main");
676        assert_ne!(a, b);
677        assert_ne!(a, c);
678        assert_ne!(b, c);
679    }
680
681    #[test]
682    fn state_key_equality() {
683        let a = StateKey::new("ScrollView", "main");
684        let b = StateKey::new("ScrollView", "main");
685        assert_eq!(a, b);
686    }
687
688    #[test]
689    fn state_key_hash_consistency() {
690        use std::collections::hash_map::DefaultHasher;
691
692        let a = StateKey::new("ScrollView", "main");
693        let b = StateKey::new("ScrollView", "main");
694
695        let hash = |key: &StateKey| {
696            let mut h = DefaultHasher::new();
697            key.hash(&mut h);
698            h.finish()
699        };
700        assert_eq!(hash(&a), hash(&b));
701    }
702
703    #[test]
704    fn state_key_display() {
705        let key = StateKey::new("ScrollView", "main");
706        assert_eq!(key.to_string(), "ScrollView::main");
707    }
708
709    #[test]
710    fn state_key_canonical() {
711        let key = StateKey::new("ScrollView", "main");
712        assert_eq!(key.canonical(), "ScrollView::main");
713    }
714
715    // ── Save/restore round-trip tests ───────────────────────────────
716
717    #[test]
718    fn save_restore_round_trip() {
719        let mut widget = TestScrollView {
720            id: "content".into(),
721            offset: 42,
722            max: 100,
723        };
724
725        let saved = widget.save_state();
726        assert_eq!(saved.scroll_offset, 42);
727
728        widget.offset = 0; // reset
729        widget.restore_state(saved);
730        assert_eq!(widget.offset, 42);
731    }
732
733    #[test]
734    fn restore_clamps_to_valid_range() {
735        let mut widget = TestScrollView {
736            id: "content".into(),
737            offset: 0,
738            max: 10,
739        };
740
741        // Stored state exceeds current max
742        widget.restore_state(ScrollState { scroll_offset: 999 });
743        assert_eq!(widget.offset, 10);
744    }
745
746    #[test]
747    fn default_state_on_missing() {
748        let mut widget = TestScrollView {
749            id: "new".into(),
750            offset: 5,
751            max: 100,
752        };
753
754        widget.restore_state(ScrollState::default());
755        assert_eq!(widget.offset, 0);
756    }
757
758    // ── Version tests ───────────────────────────────────────────────
759
760    #[test]
761    fn default_state_version_is_one() {
762        assert_eq!(TestScrollView::state_version(), 1);
763    }
764
765    #[test]
766    fn custom_state_version() {
767        assert_eq!(TestTreeView::state_version(), 2);
768    }
769
770    // ── VersionedState tests ────────────────────────────────────────
771
772    #[test]
773    fn versioned_state_pack_unpack() {
774        let widget = TestScrollView {
775            id: "main".into(),
776            offset: 77,
777            max: 100,
778        };
779
780        let packed = VersionedState::pack(&widget);
781        assert_eq!(packed.version, 1);
782        assert_eq!(packed.data.scroll_offset, 77);
783
784        let unpacked = packed.unpack::<TestScrollView>();
785        assert!(unpacked.is_some());
786        assert_eq!(unpacked.unwrap().scroll_offset, 77);
787    }
788
789    #[test]
790    fn versioned_state_version_mismatch_returns_none() {
791        // Simulate stored state from version 1, but widget expects version 2
792        let stored = VersionedState::<TreeState> {
793            version: 1,
794            data: TreeState::default(),
795        };
796
797        let result = stored.unpack::<TestTreeView>();
798        assert!(result.is_none());
799    }
800
801    #[test]
802    fn versioned_state_unpack_or_default_on_mismatch() {
803        let stored = VersionedState::<TreeState> {
804            version: 1,
805            data: TreeState {
806                expanded_nodes: vec![1, 2, 3],
807                collapse_all_on_blur: true,
808            },
809        };
810
811        let result = stored.unpack_or_default::<TestTreeView>();
812        // Should return default because version 1 != expected 2
813        assert_eq!(result, TreeState::default());
814    }
815
816    #[test]
817    fn versioned_state_unpack_or_default_on_match() {
818        let stored = VersionedState::<ScrollState> {
819            version: 1,
820            data: ScrollState { scroll_offset: 55 },
821        };
822
823        let result = stored.unpack_or_default::<TestScrollView>();
824        assert_eq!(result.scroll_offset, 55);
825    }
826
827    #[test]
828    fn versioned_state_default() {
829        let vs = VersionedState::<ScrollState>::default();
830        assert_eq!(vs.version, 1);
831        assert_eq!(vs.data, ScrollState::default());
832    }
833
834    // ── Migration System tests ─────────────────────────────────────────
835
836    #[test]
837    fn migration_error_display() {
838        let err = MigrationError::NoPathFound { from: 1, to: 3 };
839        assert_eq!(err.to_string(), "no migration path from version 1 to 3");
840
841        let err = MigrationError::MigrationFailed {
842            from: 2,
843            to: 3,
844            message: "data corrupt".into(),
845        };
846        assert_eq!(
847            err.to_string(),
848            "migration from 2 to 3 failed: data corrupt"
849        );
850
851        let err = MigrationError::InvalidVersionRange { from: 5, to: 2 };
852        assert_eq!(err.to_string(), "invalid version range: 5 to 2");
853    }
854
855    #[test]
856    fn migration_chain_new_is_empty() {
857        let chain = MigrationChain::<ScrollState>::new();
858        assert!(!chain.has_path(1, 2));
859    }
860
861    // Test migration from v1 ScrollState (just offset) to v2 (with hypothetical field)
862    #[derive(Debug, Clone, Default)]
863    struct ScrollStateV1 {
864        scroll_offset: u16,
865    }
866
867    #[derive(Debug, Clone, Default)]
868    struct ScrollStateV2 {
869        scroll_offset: u16,
870        velocity: f32, // Added in v2
871    }
872
873    struct V1ToV2Migration;
874
875    impl ErasedMigration<ScrollStateV2> for V1ToV2Migration {
876        fn from_version(&self) -> u32 {
877            1
878        }
879        fn to_version(&self) -> u32 {
880            2
881        }
882        fn migrate_erased(
883            &self,
884            old: Box<dyn core::any::Any + Send>,
885        ) -> Result<Box<dyn core::any::Any + Send>, String> {
886            let v1 = old
887                .downcast::<ScrollStateV1>()
888                .map_err(|_| "invalid state type")?;
889            Ok(Box::new(ScrollStateV2 {
890                scroll_offset: v1.scroll_offset,
891                velocity: 0.0,
892            }))
893        }
894    }
895
896    #[test]
897    fn migration_chain_register_and_has_path() {
898        let mut chain = MigrationChain::<ScrollStateV2>::new();
899        chain.register(Box::new(V1ToV2Migration));
900
901        assert!(chain.has_path(1, 2));
902        assert!(chain.has_path(1, 1)); // Same version is valid
903        assert!(chain.has_path(2, 2)); // Same version is valid
904        assert!(!chain.has_path(1, 3)); // No migration to v3
905    }
906
907    #[test]
908    #[should_panic(expected = "migration must increment version by exactly 1")]
909    fn migration_chain_rejects_non_sequential_migration() {
910        struct BadMigration;
911        impl ErasedMigration<ScrollStateV2> for BadMigration {
912            fn from_version(&self) -> u32 {
913                1
914            }
915            fn to_version(&self) -> u32 {
916                3
917            } // Skips v2!
918            fn migrate_erased(
919                &self,
920                _: Box<dyn core::any::Any + Send>,
921            ) -> Result<Box<dyn core::any::Any + Send>, String> {
922                unreachable!()
923            }
924        }
925
926        let mut chain = MigrationChain::<ScrollStateV2>::new();
927        chain.register(Box::new(BadMigration));
928    }
929
930    #[test]
931    #[should_panic(expected = "migration for version 1 already registered")]
932    fn migration_chain_rejects_duplicate_registration() {
933        let mut chain = MigrationChain::<ScrollStateV2>::new();
934        chain.register(Box::new(V1ToV2Migration));
935        chain.register(Box::new(V1ToV2Migration)); // Duplicate!
936    }
937
938    #[test]
939    fn migration_chain_migrate_success() {
940        let mut chain = MigrationChain::<ScrollStateV2>::new();
941        chain.register(Box::new(V1ToV2Migration));
942
943        let old_state = Box::new(ScrollStateV1 { scroll_offset: 42 });
944        let result = chain.migrate(old_state, 1, 2);
945
946        assert!(result.is_ok());
947        let migrated = result
948            .unwrap()
949            .downcast::<ScrollStateV2>()
950            .expect("should be ScrollStateV2");
951        assert_eq!(migrated.scroll_offset, 42);
952        assert_eq!(migrated.velocity, 0.0);
953    }
954
955    #[test]
956    fn migration_chain_migrate_same_version() {
957        let chain = MigrationChain::<ScrollStateV2>::new();
958        let state = Box::new(ScrollStateV2 {
959            scroll_offset: 10,
960            velocity: 1.5,
961        });
962
963        let result = chain.migrate(state, 2, 2);
964        assert!(result.is_ok());
965    }
966
967    #[test]
968    fn migration_chain_migrate_no_path() {
969        let chain = MigrationChain::<ScrollStateV2>::new();
970        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV1 { scroll_offset: 0 });
971
972        let result = chain.migrate(state, 1, 2);
973        assert!(matches!(
974            result,
975            Err(MigrationError::NoPathFound { from: 1, to: 2 })
976        ));
977    }
978
979    #[test]
980    fn migration_chain_migrate_invalid_range() {
981        let chain = MigrationChain::<ScrollStateV2>::new();
982        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV2::default());
983
984        let result = chain.migrate(state, 3, 1);
985        assert!(matches!(
986            result,
987            Err(MigrationError::InvalidVersionRange { from: 3, to: 1 })
988        ));
989    }
990
991    #[test]
992    fn restore_result_into_state() {
993        let direct = RestoreResult::Direct(ScrollState { scroll_offset: 10 });
994        assert_eq!(direct.into_state().scroll_offset, 10);
995
996        let migrated = RestoreResult::Migrated {
997            state: ScrollState { scroll_offset: 20 },
998            from_version: 1,
999        };
1000        assert_eq!(migrated.into_state().scroll_offset, 20);
1001
1002        let fallback = RestoreResult::DefaultFallback {
1003            error: MigrationError::NoPathFound { from: 1, to: 2 },
1004            default: ScrollState { scroll_offset: 0 },
1005        };
1006        assert_eq!(fallback.into_state().scroll_offset, 0);
1007    }
1008
1009    #[test]
1010    fn restore_result_was_migrated() {
1011        let direct = RestoreResult::Direct(ScrollState::default());
1012        assert!(!direct.was_migrated());
1013
1014        let migrated = RestoreResult::Migrated::<ScrollState> {
1015            state: ScrollState::default(),
1016            from_version: 1,
1017        };
1018        assert!(migrated.was_migrated());
1019
1020        let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1021            error: MigrationError::NoPathFound { from: 1, to: 2 },
1022            default: ScrollState::default(),
1023        };
1024        assert!(!fallback.was_migrated());
1025    }
1026
1027    #[test]
1028    fn restore_result_is_fallback() {
1029        let direct = RestoreResult::Direct(ScrollState::default());
1030        assert!(!direct.is_fallback());
1031
1032        let migrated = RestoreResult::Migrated::<ScrollState> {
1033            state: ScrollState::default(),
1034            from_version: 1,
1035        };
1036        assert!(!migrated.is_fallback());
1037
1038        let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1039            error: MigrationError::NoPathFound { from: 1, to: 2 },
1040            default: ScrollState::default(),
1041        };
1042        assert!(fallback.is_fallback());
1043    }
1044}