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    #[must_use = "use the unpacked state (if any)"]
237    pub fn unpack<W: Stateful<State = S>>(self) -> Option<S> {
238        if self.version == W::state_version() {
239            Some(self.data)
240        } else {
241            None
242        }
243    }
244
245    /// Unpack with fallback: returns the stored data if versions match,
246    /// otherwise returns `S::default()`.
247    pub fn unpack_or_default<W: Stateful<State = S>>(self) -> S
248    where
249        S: Default,
250    {
251        if self.version == W::state_version() {
252            self.data
253        } else {
254            S::default()
255        }
256    }
257}
258
259impl<S: Default> Default for VersionedState<S> {
260    fn default() -> Self {
261        Self {
262            version: 1,
263            data: S::default(),
264        }
265    }
266}
267
268// ============================================================================
269// State Migration System (bd-30g1.5)
270// ============================================================================
271
272/// Error that can occur during state migration.
273#[derive(Debug, Clone)]
274pub enum MigrationError {
275    /// No migration path exists from source to target version.
276    NoPathFound { from: u32, to: u32 },
277    /// A migration function returned an error.
278    MigrationFailed { from: u32, to: u32, message: String },
279    /// Version numbers are invalid (e.g., target < source).
280    InvalidVersionRange { from: u32, to: u32 },
281}
282
283impl core::fmt::Display for MigrationError {
284    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
285        match self {
286            Self::NoPathFound { from, to } => {
287                write!(f, "no migration path from version {} to {}", from, to)
288            }
289            Self::MigrationFailed { from, to, message } => {
290                write!(f, "migration from {} to {} failed: {}", from, to, message)
291            }
292            Self::InvalidVersionRange { from, to } => {
293                write!(f, "invalid version range: {} to {}", from, to)
294            }
295        }
296    }
297}
298
299/// A single-step migration from version N to version N+1.
300///
301/// Migrations are always forward-only and increment by exactly one version.
302/// This ensures a clear, auditable upgrade path.
303///
304/// # Example
305///
306/// ```ignore
307/// // Migration from v1 ScrollState to v2 ScrollState (adds new field)
308/// struct ScrollStateV1ToV2;
309///
310/// impl StateMigration for ScrollStateV1ToV2 {
311///     type OldState = ScrollStateV1;
312///     type NewState = ScrollStateV2;
313///
314///     fn from_version(&self) -> u32 { 1 }
315///     fn to_version(&self) -> u32 { 2 }
316///
317///     fn migrate(&self, old: ScrollStateV1) -> Result<ScrollStateV2, String> {
318///         Ok(ScrollStateV2 {
319///             scroll_offset: old.scroll_offset,
320///             scroll_velocity: 0.0, // New field, default value
321///         })
322///     }
323/// }
324/// ```
325#[allow(clippy::wrong_self_convention)]
326pub trait StateMigration {
327    /// The state type before migration.
328    type OldState;
329    /// The state type after migration.
330    type NewState;
331
332    /// Source version this migration transforms from.
333    fn from_version(&self) -> u32;
334
335    /// Target version this migration produces.
336    /// Must equal `from_version() + 1`.
337    fn to_version(&self) -> u32;
338
339    /// Perform the migration.
340    ///
341    /// Returns `Err` with a message if the migration cannot be performed.
342    fn migrate(&self, old: Self::OldState) -> Result<Self::NewState, String>;
343}
344
345/// A type-erased migration step for use in migration chains.
346///
347/// This allows storing migrations with different types in a single collection.
348#[allow(clippy::wrong_self_convention)]
349pub trait ErasedMigration<S>: Send + Sync {
350    /// Source version.
351    fn from_version(&self) -> u32;
352    /// Target version.
353    fn to_version(&self) -> u32;
354    /// Perform migration on boxed state, returning boxed result.
355    fn migrate_erased(
356        &self,
357        old: Box<dyn core::any::Any + Send>,
358    ) -> Result<Box<dyn core::any::Any + Send>, String>;
359}
360
361/// A chain of migrations that can upgrade state through multiple versions.
362///
363/// # Design
364///
365/// The migration chain executes migrations in sequence, starting from the
366/// stored version and ending at the current version. Each step increments
367/// the version by exactly one.
368///
369/// # Example
370///
371/// ```ignore
372/// let mut chain = MigrationChain::<FinalState>::new();
373/// chain.register(Box::new(V1ToV2Migration));
374/// chain.register(Box::new(V2ToV3Migration));
375///
376/// // Migrate from v1 to v3 (current)
377/// let result = chain.migrate_to_current(v1_state, 1, 3);
378/// ```
379pub struct MigrationChain<S> {
380    /// Migrations indexed by their `from_version`.
381    migrations: std::collections::HashMap<u32, Box<dyn ErasedMigration<S>>>,
382}
383
384impl<S: 'static> MigrationChain<S> {
385    /// Create an empty migration chain.
386    #[must_use]
387    pub fn new() -> Self {
388        Self {
389            migrations: std::collections::HashMap::new(),
390        }
391    }
392
393    /// Register a migration step.
394    ///
395    /// # Panics
396    ///
397    /// Panics if a migration for the same `from_version` is already registered.
398    pub fn register(&mut self, migration: Box<dyn ErasedMigration<S>>) {
399        let from = migration.from_version();
400        let to = migration.to_version();
401        assert_eq!(
402            to,
403            from + 1,
404            "migration must increment version by exactly 1 (got {} -> {})",
405            from,
406            to
407        );
408        assert!(
409            !self.migrations.contains_key(&from),
410            "migration for version {} already registered",
411            from
412        );
413        self.migrations.insert(from, migration);
414    }
415
416    /// Check if a migration path exists from `from_version` to `to_version`.
417    #[must_use]
418    pub fn has_path(&self, from_version: u32, to_version: u32) -> bool {
419        if from_version >= to_version {
420            return from_version == to_version;
421        }
422        let mut current = from_version;
423        while current < to_version {
424            if !self.migrations.contains_key(&current) {
425                return false;
426            }
427            current += 1;
428        }
429        true
430    }
431
432    /// Attempt to migrate state from `from_version` to `to_version`.
433    ///
434    /// Returns `Ok(migrated_state)` on success, or `Err` if migration fails.
435    pub fn migrate(
436        &self,
437        state: Box<dyn core::any::Any + Send>,
438        from_version: u32,
439        to_version: u32,
440    ) -> Result<Box<dyn core::any::Any + Send>, MigrationError> {
441        if from_version > to_version {
442            return Err(MigrationError::InvalidVersionRange {
443                from: from_version,
444                to: to_version,
445            });
446        }
447        if from_version == to_version {
448            return Ok(state);
449        }
450
451        let mut current_state = state;
452        let mut current_version = from_version;
453
454        while current_version < to_version {
455            let migration =
456                self.migrations
457                    .get(&current_version)
458                    .ok_or(MigrationError::NoPathFound {
459                        from: current_version,
460                        to: to_version,
461                    })?;
462
463            current_state = migration.migrate_erased(current_state).map_err(|msg| {
464                MigrationError::MigrationFailed {
465                    from: current_version,
466                    to: current_version + 1,
467                    message: msg,
468                }
469            })?;
470
471            current_version += 1;
472        }
473
474        Ok(current_state)
475    }
476}
477
478impl<S: 'static> Default for MigrationChain<S> {
479    fn default() -> Self {
480        Self::new()
481    }
482}
483
484/// Result of attempting state restoration with migration.
485#[derive(Debug)]
486pub enum RestoreResult<S> {
487    /// State was restored directly (versions matched).
488    Direct(S),
489    /// State was successfully migrated from an older version.
490    Migrated { state: S, from_version: u32 },
491    /// Migration failed; falling back to default state.
492    DefaultFallback { error: MigrationError, default: S },
493}
494
495impl<S> RestoreResult<S> {
496    /// Extract the state value, regardless of how it was obtained.
497    pub fn into_state(self) -> S {
498        match self {
499            Self::Direct(s) | Self::Migrated { state: s, .. } => s,
500            Self::DefaultFallback { default, .. } => default,
501        }
502    }
503
504    /// Returns `true` if the state was migrated.
505    #[must_use]
506    pub fn was_migrated(&self) -> bool {
507        matches!(self, Self::Migrated { .. })
508    }
509
510    /// Returns `true` if we fell back to default.
511    #[must_use]
512    pub fn is_fallback(&self) -> bool {
513        matches!(self, Self::DefaultFallback { .. })
514    }
515}
516
517impl<S> VersionedState<S> {
518    /// Attempt to unpack with migration support.
519    ///
520    /// If the stored version doesn't match the current version, attempts to
521    /// migrate through the provided chain. Falls back to default on failure.
522    ///
523    /// # Type Parameters
524    ///
525    /// - `W`: The widget type that implements `Stateful<State = S>`.
526    ///
527    /// # Example
528    ///
529    /// ```ignore
530    /// let chain = MigrationChain::new();
531    /// // ... register migrations ...
532    ///
533    /// let versioned = load_state_from_disk();
534    /// let result = versioned.unpack_with_migration::<MyWidget>(&chain);
535    /// let state = result.into_state();
536    /// ```
537    pub fn unpack_with_migration<W>(self, chain: &MigrationChain<S>) -> RestoreResult<S>
538    where
539        W: Stateful<State = S>,
540        S: Default + 'static + Send,
541    {
542        let current_version = W::state_version();
543
544        if self.version == current_version {
545            return RestoreResult::Direct(self.data);
546        }
547
548        // Try migration
549        let boxed: Box<dyn core::any::Any + Send> = Box::new(self.data);
550        match chain.migrate(boxed, self.version, current_version) {
551            Ok(migrated) => {
552                if let Ok(state) = migrated.downcast::<S>() {
553                    RestoreResult::Migrated {
554                        state: *state,
555                        from_version: self.version,
556                    }
557                } else {
558                    // Type mismatch after migration (shouldn't happen with correct chain)
559                    RestoreResult::DefaultFallback {
560                        error: MigrationError::MigrationFailed {
561                            from: self.version,
562                            to: current_version,
563                            message: "type mismatch after migration".to_string(),
564                        },
565                        default: S::default(),
566                    }
567                }
568            }
569            Err(e) => RestoreResult::DefaultFallback {
570                error: e,
571                default: S::default(),
572            },
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    // ── Test widget ─────────────────────────────────────────────────
582
583    #[derive(Default)]
584    struct TestScrollView {
585        id: String,
586        offset: u16,
587        max: u16,
588    }
589
590    #[derive(Clone, Debug, Default, PartialEq)]
591    struct ScrollState {
592        scroll_offset: u16,
593    }
594
595    impl Stateful for TestScrollView {
596        type State = ScrollState;
597
598        fn state_key(&self) -> StateKey {
599            StateKey::new("ScrollView", &self.id)
600        }
601
602        fn save_state(&self) -> ScrollState {
603            ScrollState {
604                scroll_offset: self.offset,
605            }
606        }
607
608        fn restore_state(&mut self, state: ScrollState) {
609            self.offset = state.scroll_offset.min(self.max);
610        }
611    }
612
613    // ── Another test widget with version 2 ──────────────────────────
614
615    #[derive(Default)]
616    struct TestTreeView {
617        id: String,
618        expanded: Vec<u32>,
619    }
620
621    #[derive(Clone, Debug, Default, PartialEq)]
622    struct TreeState {
623        expanded_nodes: Vec<u32>,
624        collapse_all_on_blur: bool, // added in v2
625    }
626
627    impl Stateful for TestTreeView {
628        type State = TreeState;
629
630        fn state_key(&self) -> StateKey {
631            StateKey::new("TreeView", &self.id)
632        }
633
634        fn save_state(&self) -> TreeState {
635            TreeState {
636                expanded_nodes: self.expanded.clone(),
637                collapse_all_on_blur: false,
638            }
639        }
640
641        fn restore_state(&mut self, state: TreeState) {
642            self.expanded = state.expanded_nodes;
643        }
644
645        fn state_version() -> u32 {
646            2
647        }
648    }
649
650    // ── StateKey tests ──────────────────────────────────────────────
651
652    #[test]
653    fn state_key_new() {
654        let key = StateKey::new("ScrollView", "main");
655        assert_eq!(key.widget_type, "ScrollView");
656        assert_eq!(key.instance_id, "main");
657    }
658
659    #[test]
660    fn state_key_from_path() {
661        let key = StateKey::from_path(&["app", "sidebar", "tree"]);
662        assert_eq!(key.instance_id, "app/sidebar/tree");
663        assert_eq!(key.widget_type, "tree");
664    }
665
666    #[test]
667    #[should_panic(expected = "non-empty path")]
668    fn state_key_from_empty_path_panics() {
669        let _ = StateKey::from_path(&[]);
670    }
671
672    #[test]
673    fn state_key_uniqueness() {
674        let a = StateKey::new("ScrollView", "main");
675        let b = StateKey::new("ScrollView", "sidebar");
676        let c = StateKey::new("TreeView", "main");
677        assert_ne!(a, b);
678        assert_ne!(a, c);
679        assert_ne!(b, c);
680    }
681
682    #[test]
683    fn state_key_equality() {
684        let a = StateKey::new("ScrollView", "main");
685        let b = StateKey::new("ScrollView", "main");
686        assert_eq!(a, b);
687    }
688
689    #[test]
690    fn state_key_hash_consistency() {
691        use std::collections::hash_map::DefaultHasher;
692
693        let a = StateKey::new("ScrollView", "main");
694        let b = StateKey::new("ScrollView", "main");
695
696        let hash = |key: &StateKey| {
697            let mut h = DefaultHasher::new();
698            key.hash(&mut h);
699            h.finish()
700        };
701        assert_eq!(hash(&a), hash(&b));
702    }
703
704    #[test]
705    fn state_key_display() {
706        let key = StateKey::new("ScrollView", "main");
707        assert_eq!(key.to_string(), "ScrollView::main");
708    }
709
710    #[test]
711    fn state_key_canonical() {
712        let key = StateKey::new("ScrollView", "main");
713        assert_eq!(key.canonical(), "ScrollView::main");
714    }
715
716    // ── Save/restore round-trip tests ───────────────────────────────
717
718    #[test]
719    fn save_restore_round_trip() {
720        let mut widget = TestScrollView {
721            id: "content".into(),
722            offset: 42,
723            max: 100,
724        };
725
726        let saved = widget.save_state();
727        assert_eq!(saved.scroll_offset, 42);
728
729        widget.offset = 0; // reset
730        widget.restore_state(saved);
731        assert_eq!(widget.offset, 42);
732    }
733
734    #[test]
735    fn restore_clamps_to_valid_range() {
736        let mut widget = TestScrollView {
737            id: "content".into(),
738            offset: 0,
739            max: 10,
740        };
741
742        // Stored state exceeds current max
743        widget.restore_state(ScrollState { scroll_offset: 999 });
744        assert_eq!(widget.offset, 10);
745    }
746
747    #[test]
748    fn default_state_on_missing() {
749        let mut widget = TestScrollView {
750            id: "new".into(),
751            offset: 5,
752            max: 100,
753        };
754
755        widget.restore_state(ScrollState::default());
756        assert_eq!(widget.offset, 0);
757    }
758
759    // ── Version tests ───────────────────────────────────────────────
760
761    #[test]
762    fn default_state_version_is_one() {
763        assert_eq!(TestScrollView::state_version(), 1);
764    }
765
766    #[test]
767    fn custom_state_version() {
768        assert_eq!(TestTreeView::state_version(), 2);
769    }
770
771    // ── VersionedState tests ────────────────────────────────────────
772
773    #[test]
774    fn versioned_state_pack_unpack() {
775        let widget = TestScrollView {
776            id: "main".into(),
777            offset: 77,
778            max: 100,
779        };
780
781        let packed = VersionedState::pack(&widget);
782        assert_eq!(packed.version, 1);
783        assert_eq!(packed.data.scroll_offset, 77);
784
785        let unpacked = packed.unpack::<TestScrollView>();
786        assert!(unpacked.is_some());
787        assert_eq!(unpacked.unwrap().scroll_offset, 77);
788    }
789
790    #[test]
791    fn versioned_state_version_mismatch_returns_none() {
792        // Simulate stored state from version 1, but widget expects version 2
793        let stored = VersionedState::<TreeState> {
794            version: 1,
795            data: TreeState::default(),
796        };
797
798        let result = stored.unpack::<TestTreeView>();
799        assert!(result.is_none());
800    }
801
802    #[test]
803    fn versioned_state_unpack_or_default_on_mismatch() {
804        let stored = VersionedState::<TreeState> {
805            version: 1,
806            data: TreeState {
807                expanded_nodes: vec![1, 2, 3],
808                collapse_all_on_blur: true,
809            },
810        };
811
812        let result = stored.unpack_or_default::<TestTreeView>();
813        // Should return default because version 1 != expected 2
814        assert_eq!(result, TreeState::default());
815    }
816
817    #[test]
818    fn versioned_state_unpack_or_default_on_match() {
819        let stored = VersionedState::<ScrollState> {
820            version: 1,
821            data: ScrollState { scroll_offset: 55 },
822        };
823
824        let result = stored.unpack_or_default::<TestScrollView>();
825        assert_eq!(result.scroll_offset, 55);
826    }
827
828    #[test]
829    fn versioned_state_default() {
830        let vs = VersionedState::<ScrollState>::default();
831        assert_eq!(vs.version, 1);
832        assert_eq!(vs.data, ScrollState::default());
833    }
834
835    // ── Migration System tests ─────────────────────────────────────────
836
837    #[test]
838    fn migration_error_display() {
839        let err = MigrationError::NoPathFound { from: 1, to: 3 };
840        assert_eq!(err.to_string(), "no migration path from version 1 to 3");
841
842        let err = MigrationError::MigrationFailed {
843            from: 2,
844            to: 3,
845            message: "data corrupt".into(),
846        };
847        assert_eq!(
848            err.to_string(),
849            "migration from 2 to 3 failed: data corrupt"
850        );
851
852        let err = MigrationError::InvalidVersionRange { from: 5, to: 2 };
853        assert_eq!(err.to_string(), "invalid version range: 5 to 2");
854    }
855
856    #[test]
857    fn migration_chain_new_is_empty() {
858        let chain = MigrationChain::<ScrollState>::new();
859        assert!(!chain.has_path(1, 2));
860    }
861
862    // Test migration from v1 ScrollState (just offset) to v2 (with hypothetical field)
863    #[derive(Debug, Clone, Default)]
864    struct ScrollStateV1 {
865        scroll_offset: u16,
866    }
867
868    #[derive(Debug, Clone, Default)]
869    struct ScrollStateV2 {
870        scroll_offset: u16,
871        velocity: f32, // Added in v2
872    }
873
874    struct V1ToV2Migration;
875
876    impl ErasedMigration<ScrollStateV2> for V1ToV2Migration {
877        fn from_version(&self) -> u32 {
878            1
879        }
880        fn to_version(&self) -> u32 {
881            2
882        }
883        fn migrate_erased(
884            &self,
885            old: Box<dyn core::any::Any + Send>,
886        ) -> Result<Box<dyn core::any::Any + Send>, String> {
887            let v1 = old
888                .downcast::<ScrollStateV1>()
889                .map_err(|_| "invalid state type")?;
890            Ok(Box::new(ScrollStateV2 {
891                scroll_offset: v1.scroll_offset,
892                velocity: 0.0,
893            }))
894        }
895    }
896
897    #[test]
898    fn migration_chain_register_and_has_path() {
899        let mut chain = MigrationChain::<ScrollStateV2>::new();
900        chain.register(Box::new(V1ToV2Migration));
901
902        assert!(chain.has_path(1, 2));
903        assert!(chain.has_path(1, 1)); // Same version is valid
904        assert!(chain.has_path(2, 2)); // Same version is valid
905        assert!(!chain.has_path(1, 3)); // No migration to v3
906    }
907
908    #[test]
909    #[should_panic(expected = "migration must increment version by exactly 1")]
910    fn migration_chain_rejects_non_sequential_migration() {
911        struct BadMigration;
912        impl ErasedMigration<ScrollStateV2> for BadMigration {
913            fn from_version(&self) -> u32 {
914                1
915            }
916            fn to_version(&self) -> u32 {
917                3
918            } // Skips v2!
919            fn migrate_erased(
920                &self,
921                _: Box<dyn core::any::Any + Send>,
922            ) -> Result<Box<dyn core::any::Any + Send>, String> {
923                unreachable!()
924            }
925        }
926
927        let mut chain = MigrationChain::<ScrollStateV2>::new();
928        chain.register(Box::new(BadMigration));
929    }
930
931    #[test]
932    #[should_panic(expected = "migration for version 1 already registered")]
933    fn migration_chain_rejects_duplicate_registration() {
934        let mut chain = MigrationChain::<ScrollStateV2>::new();
935        chain.register(Box::new(V1ToV2Migration));
936        chain.register(Box::new(V1ToV2Migration)); // Duplicate!
937    }
938
939    #[test]
940    fn migration_chain_migrate_success() {
941        let mut chain = MigrationChain::<ScrollStateV2>::new();
942        chain.register(Box::new(V1ToV2Migration));
943
944        let old_state = Box::new(ScrollStateV1 { scroll_offset: 42 });
945        let result = chain.migrate(old_state, 1, 2);
946
947        assert!(result.is_ok());
948        let migrated = result
949            .unwrap()
950            .downcast::<ScrollStateV2>()
951            .expect("should be ScrollStateV2");
952        assert_eq!(migrated.scroll_offset, 42);
953        assert_eq!(migrated.velocity, 0.0);
954    }
955
956    #[test]
957    fn migration_chain_migrate_same_version() {
958        let chain = MigrationChain::<ScrollStateV2>::new();
959        let state = Box::new(ScrollStateV2 {
960            scroll_offset: 10,
961            velocity: 1.5,
962        });
963
964        let result = chain.migrate(state, 2, 2);
965        assert!(result.is_ok());
966    }
967
968    #[test]
969    fn migration_chain_migrate_no_path() {
970        let chain = MigrationChain::<ScrollStateV2>::new();
971        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV1 { scroll_offset: 0 });
972
973        let result = chain.migrate(state, 1, 2);
974        assert!(matches!(
975            result,
976            Err(MigrationError::NoPathFound { from: 1, to: 2 })
977        ));
978    }
979
980    #[test]
981    fn migration_chain_migrate_invalid_range() {
982        let chain = MigrationChain::<ScrollStateV2>::new();
983        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV2::default());
984
985        let result = chain.migrate(state, 3, 1);
986        assert!(matches!(
987            result,
988            Err(MigrationError::InvalidVersionRange { from: 3, to: 1 })
989        ));
990    }
991
992    #[test]
993    fn restore_result_into_state() {
994        let direct = RestoreResult::Direct(ScrollState { scroll_offset: 10 });
995        assert_eq!(direct.into_state().scroll_offset, 10);
996
997        let migrated = RestoreResult::Migrated {
998            state: ScrollState { scroll_offset: 20 },
999            from_version: 1,
1000        };
1001        assert_eq!(migrated.into_state().scroll_offset, 20);
1002
1003        let fallback = RestoreResult::DefaultFallback {
1004            error: MigrationError::NoPathFound { from: 1, to: 2 },
1005            default: ScrollState { scroll_offset: 0 },
1006        };
1007        assert_eq!(fallback.into_state().scroll_offset, 0);
1008    }
1009
1010    #[test]
1011    fn restore_result_was_migrated() {
1012        let direct = RestoreResult::Direct(ScrollState::default());
1013        assert!(!direct.was_migrated());
1014
1015        let migrated = RestoreResult::Migrated::<ScrollState> {
1016            state: ScrollState::default(),
1017            from_version: 1,
1018        };
1019        assert!(migrated.was_migrated());
1020
1021        let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1022            error: MigrationError::NoPathFound { from: 1, to: 2 },
1023            default: ScrollState::default(),
1024        };
1025        assert!(!fallback.was_migrated());
1026    }
1027
1028    #[test]
1029    fn restore_result_is_fallback() {
1030        let direct = RestoreResult::Direct(ScrollState::default());
1031        assert!(!direct.is_fallback());
1032
1033        let migrated = RestoreResult::Migrated::<ScrollState> {
1034            state: ScrollState::default(),
1035            from_version: 1,
1036        };
1037        assert!(!migrated.is_fallback());
1038
1039        let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1040            error: MigrationError::NoPathFound { from: 1, to: 2 },
1041            default: ScrollState::default(),
1042        };
1043        assert!(fallback.is_fallback());
1044    }
1045
1046    // ── Edge-case: StateKey ──────────────────────────────────────────
1047
1048    #[test]
1049    fn state_key_from_path_single_segment() {
1050        let key = StateKey::from_path(&["widget"]);
1051        assert_eq!(key.widget_type, "widget");
1052        assert_eq!(key.instance_id, "widget");
1053    }
1054
1055    #[test]
1056    fn state_key_from_path_two_segments() {
1057        let key = StateKey::from_path(&["parent", "child"]);
1058        assert_eq!(key.widget_type, "child");
1059        assert_eq!(key.instance_id, "parent/child");
1060    }
1061
1062    #[test]
1063    fn state_key_empty_instance_id() {
1064        let key = StateKey::new("Widget", "");
1065        assert_eq!(key.instance_id, "");
1066        assert_eq!(key.canonical(), "Widget::");
1067        assert_eq!(key.to_string(), "Widget::");
1068    }
1069
1070    #[test]
1071    fn state_key_canonical_matches_display() {
1072        let key = StateKey::new("TreeView", "sidebar/nav");
1073        assert_eq!(key.canonical(), key.to_string());
1074    }
1075
1076    #[test]
1077    fn state_key_clone() {
1078        let key = StateKey::new("Scroll", "main");
1079        let cloned = key.clone();
1080        assert_eq!(key, cloned);
1081        assert_eq!(key.widget_type, cloned.widget_type);
1082        assert_eq!(key.instance_id, cloned.instance_id);
1083    }
1084
1085    #[test]
1086    fn state_key_debug_format() {
1087        let key = StateKey::new("Foo", "bar");
1088        let dbg = format!("{:?}", key);
1089        assert!(dbg.contains("Foo"));
1090        assert!(dbg.contains("bar"));
1091    }
1092
1093    #[test]
1094    fn state_key_hash_differs_for_different_keys() {
1095        use std::collections::hash_map::DefaultHasher;
1096
1097        let hash = |key: &StateKey| {
1098            let mut h = DefaultHasher::new();
1099            key.hash(&mut h);
1100            h.finish()
1101        };
1102
1103        let a = StateKey::new("ScrollView", "main");
1104        let b = StateKey::new("ScrollView", "sidebar");
1105        let c = StateKey::new("TreeView", "main");
1106
1107        // Different instance_id → different hash (overwhelmingly likely)
1108        assert_ne!(hash(&a), hash(&b));
1109        // Different widget_type → different hash
1110        assert_ne!(hash(&a), hash(&c));
1111    }
1112
1113    #[test]
1114    fn state_key_usable_as_hashmap_key() {
1115        use std::collections::HashMap;
1116
1117        let mut map = HashMap::new();
1118        let key1 = StateKey::new("Scroll", "a");
1119        let key2 = StateKey::new("Scroll", "b");
1120
1121        map.insert(key1.clone(), 1);
1122        map.insert(key2.clone(), 2);
1123
1124        assert_eq!(map.get(&key1), Some(&1));
1125        assert_eq!(map.get(&key2), Some(&2));
1126        assert_eq!(map.len(), 2);
1127    }
1128
1129    #[test]
1130    fn state_key_from_path_with_empty_segments() {
1131        let key = StateKey::from_path(&["", "child"]);
1132        assert_eq!(key.instance_id, "/child");
1133        assert_eq!(key.widget_type, "child");
1134    }
1135
1136    // ── Edge-case: Stateful trait ────────────────────────────────────
1137
1138    #[test]
1139    fn save_state_on_default_widget() {
1140        let widget = TestScrollView::default();
1141        let state = widget.save_state();
1142        assert_eq!(state.scroll_offset, 0);
1143    }
1144
1145    #[test]
1146    fn restore_state_to_zero_max() {
1147        let mut widget = TestScrollView {
1148            id: "x".into(),
1149            offset: 0,
1150            max: 0,
1151        };
1152        widget.restore_state(ScrollState { scroll_offset: 100 });
1153        // Should clamp to max=0
1154        assert_eq!(widget.offset, 0);
1155    }
1156
1157    #[test]
1158    fn save_restore_preserves_max_u16() {
1159        let mut widget = TestScrollView {
1160            id: "w".into(),
1161            offset: u16::MAX,
1162            max: u16::MAX,
1163        };
1164        let saved = widget.save_state();
1165        assert_eq!(saved.scroll_offset, u16::MAX);
1166
1167        widget.offset = 0;
1168        widget.restore_state(saved);
1169        assert_eq!(widget.offset, u16::MAX);
1170    }
1171
1172    #[test]
1173    fn multiple_save_restore_cycles() {
1174        let mut widget = TestScrollView {
1175            id: "cycle".into(),
1176            offset: 10,
1177            max: 100,
1178        };
1179
1180        for i in 0..5 {
1181            widget.offset = i * 10;
1182            let saved = widget.save_state();
1183            widget.offset = 0;
1184            widget.restore_state(saved);
1185            assert_eq!(widget.offset, i * 10);
1186        }
1187    }
1188
1189    #[test]
1190    fn state_key_from_widget() {
1191        let widget = TestScrollView {
1192            id: "content-panel".into(),
1193            offset: 0,
1194            max: 50,
1195        };
1196        let key = widget.state_key();
1197        assert_eq!(key.widget_type, "ScrollView");
1198        assert_eq!(key.instance_id, "content-panel");
1199    }
1200
1201    #[test]
1202    fn tree_view_save_restore_round_trip() {
1203        let mut widget = TestTreeView {
1204            id: "files".into(),
1205            expanded: vec![1, 3, 5],
1206        };
1207        let saved = widget.save_state();
1208        assert_eq!(saved.expanded_nodes, vec![1, 3, 5]);
1209        assert!(!saved.collapse_all_on_blur);
1210
1211        widget.expanded = vec![];
1212        widget.restore_state(saved);
1213        assert_eq!(widget.expanded, vec![1, 3, 5]);
1214    }
1215
1216    // ── Edge-case: VersionedState ────────────────────────────────────
1217
1218    #[test]
1219    fn versioned_state_new_constructor() {
1220        let vs = VersionedState::new(42, ScrollState { scroll_offset: 7 });
1221        assert_eq!(vs.version, 42);
1222        assert_eq!(vs.data.scroll_offset, 7);
1223    }
1224
1225    #[test]
1226    fn versioned_state_clone() {
1227        let vs = VersionedState::new(1, ScrollState { scroll_offset: 5 });
1228        let cloned = vs.clone();
1229        assert_eq!(cloned.version, 1);
1230        assert_eq!(cloned.data.scroll_offset, 5);
1231    }
1232
1233    #[test]
1234    fn versioned_state_debug() {
1235        let vs = VersionedState::new(3, ScrollState { scroll_offset: 10 });
1236        let dbg = format!("{:?}", vs);
1237        assert!(dbg.contains("3"));
1238        assert!(dbg.contains("10"));
1239    }
1240
1241    #[test]
1242    fn versioned_state_unpack_version_match() {
1243        let vs = VersionedState::new(1, ScrollState { scroll_offset: 42 });
1244        let result = vs.unpack::<TestScrollView>();
1245        assert!(result.is_some());
1246        assert_eq!(result.unwrap().scroll_offset, 42);
1247    }
1248
1249    #[test]
1250    fn versioned_state_unpack_version_zero_mismatch() {
1251        // Version 0 doesn't match TestScrollView's version 1
1252        let vs = VersionedState::new(0, ScrollState { scroll_offset: 99 });
1253        assert!(vs.unpack::<TestScrollView>().is_none());
1254    }
1255
1256    #[test]
1257    fn versioned_state_unpack_future_version() {
1258        // Version 999 doesn't match TestScrollView's version 1
1259        let vs = VersionedState::new(999, ScrollState { scroll_offset: 1 });
1260        assert!(vs.unpack::<TestScrollView>().is_none());
1261    }
1262
1263    #[test]
1264    fn versioned_state_unpack_or_default_version_zero() {
1265        let vs = VersionedState::new(0, ScrollState { scroll_offset: 50 });
1266        let result = vs.unpack_or_default::<TestScrollView>();
1267        assert_eq!(result, ScrollState::default());
1268    }
1269
1270    #[test]
1271    fn versioned_state_default_for_tree_state() {
1272        let vs = VersionedState::<TreeState>::default();
1273        assert_eq!(vs.version, 1);
1274        assert!(vs.data.expanded_nodes.is_empty());
1275        assert!(!vs.data.collapse_all_on_blur);
1276    }
1277
1278    // ── Edge-case: MigrationError ────────────────────────────────────
1279
1280    #[test]
1281    fn migration_error_clone() {
1282        let err = MigrationError::NoPathFound { from: 1, to: 5 };
1283        let cloned = err.clone();
1284        assert_eq!(cloned.to_string(), "no migration path from version 1 to 5");
1285
1286        let err2 = MigrationError::MigrationFailed {
1287            from: 2,
1288            to: 3,
1289            message: "oops".into(),
1290        };
1291        let cloned2 = err2.clone();
1292        assert_eq!(cloned2.to_string(), "migration from 2 to 3 failed: oops");
1293
1294        let err3 = MigrationError::InvalidVersionRange { from: 10, to: 1 };
1295        let cloned3 = err3.clone();
1296        assert_eq!(cloned3.to_string(), "invalid version range: 10 to 1");
1297    }
1298
1299    #[test]
1300    fn migration_error_debug() {
1301        let err = MigrationError::NoPathFound { from: 1, to: 2 };
1302        let dbg = format!("{:?}", err);
1303        assert!(dbg.contains("NoPathFound"));
1304    }
1305
1306    // ── Edge-case: MigrationChain ────────────────────────────────────
1307
1308    #[test]
1309    fn migration_chain_default() {
1310        let chain = MigrationChain::<ScrollState>::default();
1311        assert!(!chain.has_path(1, 2));
1312    }
1313
1314    #[test]
1315    fn migration_chain_has_path_same_version() {
1316        let chain = MigrationChain::<ScrollState>::new();
1317        // Same version always returns true even with empty chain
1318        assert!(chain.has_path(0, 0));
1319        assert!(chain.has_path(5, 5));
1320        assert!(chain.has_path(u32::MAX, u32::MAX));
1321    }
1322
1323    #[test]
1324    fn migration_chain_has_path_from_greater_than_to() {
1325        let chain = MigrationChain::<ScrollState>::new();
1326        // from > to returns false (not equal)
1327        assert!(!chain.has_path(3, 1));
1328        assert!(!chain.has_path(2, 1));
1329    }
1330
1331    #[test]
1332    fn migration_chain_has_path_gap_in_chain() {
1333        // Register v1→v2, but not v2→v3, then check v1→v3
1334        let mut chain = MigrationChain::<ScrollStateV2>::new();
1335        chain.register(Box::new(V1ToV2Migration));
1336        assert!(chain.has_path(1, 2));
1337        assert!(!chain.has_path(1, 3)); // gap at v2→v3
1338    }
1339
1340    #[test]
1341    fn migration_chain_migrate_same_version_empty_chain() {
1342        let chain = MigrationChain::<ScrollState>::new();
1343        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollState { scroll_offset: 77 });
1344        let result = chain.migrate(state, 5, 5);
1345        assert!(result.is_ok());
1346        let out = result.unwrap().downcast::<ScrollState>().unwrap();
1347        assert_eq!(out.scroll_offset, 77);
1348    }
1349
1350    #[test]
1351    fn migration_chain_migrate_invalid_range_adjacent() {
1352        let chain = MigrationChain::<ScrollState>::new();
1353        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollState::default());
1354        let result = chain.migrate(state, 2, 1);
1355        assert!(matches!(
1356            result,
1357            Err(MigrationError::InvalidVersionRange { from: 2, to: 1 })
1358        ));
1359    }
1360
1361    // Multi-step migration v1→v2→v3
1362    #[derive(Debug, Clone, Default)]
1363    struct ScrollStateV3 {
1364        scroll_offset: u16,
1365        velocity: f32,
1366        smooth_scroll: bool, // Added in v3
1367    }
1368
1369    struct V2ToV3Migration;
1370
1371    impl ErasedMigration<ScrollStateV3> for V2ToV3Migration {
1372        fn from_version(&self) -> u32 {
1373            2
1374        }
1375        fn to_version(&self) -> u32 {
1376            3
1377        }
1378        fn migrate_erased(
1379            &self,
1380            old: Box<dyn core::any::Any + Send>,
1381        ) -> Result<Box<dyn core::any::Any + Send>, String> {
1382            let v2 = old
1383                .downcast::<ScrollStateV2>()
1384                .map_err(|_| "invalid state type")?;
1385            Ok(Box::new(ScrollStateV3 {
1386                scroll_offset: v2.scroll_offset,
1387                velocity: v2.velocity,
1388                smooth_scroll: true, // default for new field
1389            }))
1390        }
1391    }
1392
1393    struct V1ToV2ForV3Migration;
1394
1395    impl ErasedMigration<ScrollStateV3> for V1ToV2ForV3Migration {
1396        fn from_version(&self) -> u32 {
1397            1
1398        }
1399        fn to_version(&self) -> u32 {
1400            2
1401        }
1402        fn migrate_erased(
1403            &self,
1404            old: Box<dyn core::any::Any + Send>,
1405        ) -> Result<Box<dyn core::any::Any + Send>, String> {
1406            let v1 = old
1407                .downcast::<ScrollStateV1>()
1408                .map_err(|_| "invalid state type")?;
1409            Ok(Box::new(ScrollStateV2 {
1410                scroll_offset: v1.scroll_offset,
1411                velocity: 0.0,
1412            }))
1413        }
1414    }
1415
1416    #[test]
1417    fn migration_chain_multi_step_v1_to_v3() {
1418        let mut chain = MigrationChain::<ScrollStateV3>::new();
1419        chain.register(Box::new(V1ToV2ForV3Migration));
1420        chain.register(Box::new(V2ToV3Migration));
1421
1422        assert!(chain.has_path(1, 3));
1423        assert!(chain.has_path(1, 2));
1424        assert!(chain.has_path(2, 3));
1425
1426        let old = Box::new(ScrollStateV1 { scroll_offset: 55 });
1427        let result = chain.migrate(old, 1, 3);
1428        assert!(result.is_ok());
1429
1430        let migrated = result.unwrap().downcast::<ScrollStateV3>().unwrap();
1431        assert_eq!(migrated.scroll_offset, 55);
1432        assert_eq!(migrated.velocity, 0.0);
1433        assert!(migrated.smooth_scroll);
1434    }
1435
1436    // Failing migration
1437    struct FailingMigration;
1438
1439    impl ErasedMigration<ScrollStateV2> for FailingMigration {
1440        fn from_version(&self) -> u32 {
1441            1
1442        }
1443        fn to_version(&self) -> u32 {
1444            2
1445        }
1446        fn migrate_erased(
1447            &self,
1448            _: Box<dyn core::any::Any + Send>,
1449        ) -> Result<Box<dyn core::any::Any + Send>, String> {
1450            Err("data corruption detected".into())
1451        }
1452    }
1453
1454    #[test]
1455    fn migration_chain_migrate_failure() {
1456        let mut chain = MigrationChain::<ScrollStateV2>::new();
1457        chain.register(Box::new(FailingMigration));
1458
1459        let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV1 { scroll_offset: 1 });
1460        let result = chain.migrate(state, 1, 2);
1461        assert!(result.is_err());
1462        match result.unwrap_err() {
1463            MigrationError::MigrationFailed { from, to, message } => {
1464                assert_eq!(from, 1);
1465                assert_eq!(to, 2);
1466                assert_eq!(message, "data corruption detected");
1467            }
1468            other => panic!("expected MigrationFailed, got {:?}", other),
1469        }
1470    }
1471
1472    #[test]
1473    fn migration_chain_type_mismatch_in_migrate_erased() {
1474        let mut chain = MigrationChain::<ScrollStateV2>::new();
1475        chain.register(Box::new(V1ToV2Migration));
1476
1477        // Pass wrong type (String instead of ScrollStateV1)
1478        let wrong: Box<dyn core::any::Any + Send> = Box::new("not a state".to_string());
1479        let result = chain.migrate(wrong, 1, 2);
1480        assert!(result.is_err());
1481        match result.unwrap_err() {
1482            MigrationError::MigrationFailed { from: 1, to: 2, .. } => {}
1483            other => panic!("expected MigrationFailed, got {:?}", other),
1484        }
1485    }
1486
1487    // ── Edge-case: RestoreResult ─────────────────────────────────────
1488
1489    #[test]
1490    fn restore_result_debug() {
1491        let direct = RestoreResult::Direct(ScrollState { scroll_offset: 1 });
1492        let dbg = format!("{:?}", direct);
1493        assert!(dbg.contains("Direct"));
1494
1495        let migrated = RestoreResult::Migrated {
1496            state: ScrollState { scroll_offset: 2 },
1497            from_version: 1,
1498        };
1499        let dbg = format!("{:?}", migrated);
1500        assert!(dbg.contains("Migrated"));
1501
1502        let fallback = RestoreResult::DefaultFallback {
1503            error: MigrationError::NoPathFound { from: 1, to: 2 },
1504            default: ScrollState::default(),
1505        };
1506        let dbg = format!("{:?}", fallback);
1507        assert!(dbg.contains("DefaultFallback"));
1508    }
1509
1510    #[test]
1511    fn restore_result_into_state_migrated_with_data() {
1512        let result = RestoreResult::Migrated {
1513            state: ScrollState { scroll_offset: 99 },
1514            from_version: 1,
1515        };
1516        assert_eq!(result.into_state().scroll_offset, 99);
1517    }
1518
1519    // ── Edge-case: unpack_with_migration ─────────────────────────────
1520
1521    // Widget for unpack_with_migration tests
1522    #[derive(Default)]
1523    struct WidgetV2 {
1524        data: ScrollStateV2,
1525    }
1526
1527    impl Stateful for WidgetV2 {
1528        type State = ScrollStateV2;
1529
1530        fn state_key(&self) -> StateKey {
1531            StateKey::new("WidgetV2", "test")
1532        }
1533
1534        fn save_state(&self) -> ScrollStateV2 {
1535            self.data.clone()
1536        }
1537
1538        fn restore_state(&mut self, state: ScrollStateV2) {
1539            self.data = state;
1540        }
1541
1542        fn state_version() -> u32 {
1543            2
1544        }
1545    }
1546
1547    #[test]
1548    fn unpack_with_migration_direct_match() {
1549        let vs = VersionedState::new(
1550            2,
1551            ScrollStateV2 {
1552                scroll_offset: 33,
1553                velocity: 1.5,
1554            },
1555        );
1556        let chain = MigrationChain::<ScrollStateV2>::new();
1557        let result = vs.unpack_with_migration::<WidgetV2>(&chain);
1558
1559        assert!(matches!(&result, RestoreResult::Direct(_)));
1560        assert!(!result.was_migrated());
1561        assert!(!result.is_fallback());
1562        let state = result.into_state();
1563        assert_eq!(state.scroll_offset, 33);
1564        assert_eq!(state.velocity, 1.5);
1565    }
1566
1567    #[test]
1568    fn unpack_with_migration_version_mismatch_type_mismatch_falls_back() {
1569        // VersionedState<S> requires S == Stateful::State (ScrollStateV2 for WidgetV2).
1570        // When version != current, migrate_erased receives Box<ScrollStateV2> but
1571        // the V1ToV2Migration expects Box<ScrollStateV1> → downcast fails → fallback.
1572        let vs = VersionedState::new(1, ScrollStateV2::default());
1573
1574        let mut chain = MigrationChain::<ScrollStateV2>::new();
1575        chain.register(Box::new(V1ToV2Migration));
1576
1577        let result = vs.unpack_with_migration::<WidgetV2>(&chain);
1578        assert!(result.is_fallback());
1579        assert!(!result.was_migrated());
1580    }
1581
1582    #[test]
1583    fn unpack_with_migration_no_path_falls_back() {
1584        let vs = VersionedState::new(
1585            1,
1586            ScrollStateV2 {
1587                scroll_offset: 10,
1588                velocity: 0.0,
1589            },
1590        );
1591        // Empty chain — no migration path from v1 to v2
1592        let chain = MigrationChain::<ScrollStateV2>::new();
1593        let result = vs.unpack_with_migration::<WidgetV2>(&chain);
1594
1595        assert!(result.is_fallback());
1596        let state = result.into_state();
1597        // Should be default
1598        assert_eq!(state.scroll_offset, 0);
1599        assert_eq!(state.velocity, 0.0);
1600    }
1601
1602    #[test]
1603    fn unpack_with_migration_failed_migration_falls_back() {
1604        let vs = VersionedState::new(1, ScrollStateV2::default());
1605
1606        let mut chain = MigrationChain::<ScrollStateV2>::new();
1607        chain.register(Box::new(FailingMigration));
1608
1609        let result = vs.unpack_with_migration::<WidgetV2>(&chain);
1610        assert!(result.is_fallback());
1611    }
1612
1613    #[test]
1614    fn unpack_with_migration_type_mismatch_after_chain() {
1615        // Migration succeeds but returns wrong type → DefaultFallback
1616        struct WrongTypeMigration;
1617
1618        impl ErasedMigration<ScrollStateV2> for WrongTypeMigration {
1619            fn from_version(&self) -> u32 {
1620                1
1621            }
1622            fn to_version(&self) -> u32 {
1623                2
1624            }
1625            fn migrate_erased(
1626                &self,
1627                _: Box<dyn core::any::Any + Send>,
1628            ) -> Result<Box<dyn core::any::Any + Send>, String> {
1629                // Return wrong type (String instead of ScrollStateV2)
1630                Ok(Box::new("wrong type".to_string()))
1631            }
1632        }
1633
1634        let vs = VersionedState::new(1, ScrollStateV2::default());
1635        let mut chain = MigrationChain::<ScrollStateV2>::new();
1636        chain.register(Box::new(WrongTypeMigration));
1637
1638        let result = vs.unpack_with_migration::<WidgetV2>(&chain);
1639        assert!(result.is_fallback());
1640    }
1641
1642    // ── Edge-case: VersionedState::pack ──────────────────────────────
1643
1644    #[test]
1645    fn versioned_state_pack_uses_state_version() {
1646        let widget = TestTreeView {
1647            id: "test".into(),
1648            expanded: vec![1, 2],
1649        };
1650        let packed = VersionedState::pack(&widget);
1651        assert_eq!(packed.version, 2); // TestTreeView::state_version() == 2
1652        assert_eq!(packed.data.expanded_nodes, vec![1, 2]);
1653    }
1654
1655    #[test]
1656    fn versioned_state_pack_default_version() {
1657        let widget = TestScrollView {
1658            id: "test".into(),
1659            offset: 0,
1660            max: 100,
1661        };
1662        let packed = VersionedState::pack(&widget);
1663        assert_eq!(packed.version, 1); // default state_version() == 1
1664    }
1665
1666    // ── Edge-case: ScrollState trait coverage ────────────────────────
1667
1668    #[test]
1669    fn scroll_state_clone() {
1670        let s = ScrollState { scroll_offset: 42 };
1671        let cloned = s.clone();
1672        assert_eq!(s, cloned);
1673    }
1674
1675    #[test]
1676    fn scroll_state_debug() {
1677        let s = ScrollState { scroll_offset: 10 };
1678        let dbg = format!("{:?}", s);
1679        assert!(dbg.contains("ScrollState"));
1680        assert!(dbg.contains("10"));
1681    }
1682
1683    #[test]
1684    fn tree_state_clone() {
1685        let s = TreeState {
1686            expanded_nodes: vec![1, 2, 3],
1687            collapse_all_on_blur: true,
1688        };
1689        let cloned = s.clone();
1690        assert_eq!(s, cloned);
1691    }
1692
1693    #[test]
1694    fn tree_state_debug() {
1695        let s = TreeState {
1696            expanded_nodes: vec![],
1697            collapse_all_on_blur: false,
1698        };
1699        let dbg = format!("{:?}", s);
1700        assert!(dbg.contains("TreeState"));
1701    }
1702}