1use core::fmt;
42use core::hash::{Hash, Hasher};
43
44#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct StateKey {
64 pub widget_type: &'static str,
66 pub instance_id: String,
68}
69
70impl StateKey {
71 #[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 #[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 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 #[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
125pub trait Stateful: Sized {
166 type State: Default;
170
171 fn state_key(&self) -> StateKey;
175
176 fn save_state(&self) -> Self::State;
180
181 fn restore_state(&mut self, state: Self::State);
186
187 fn state_version() -> u32 {
193 1
194 }
195}
196
197#[derive(Clone, Debug)]
208#[cfg_attr(
209 feature = "state-persistence",
210 derive(serde::Serialize, serde::Deserialize)
211)]
212pub struct VersionedState<S> {
213 pub version: u32,
215 pub data: S,
217}
218
219impl<S> VersionedState<S> {
220 #[must_use]
222 pub fn new(version: u32, data: S) -> Self {
223 Self { version, data }
224 }
225
226 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 #[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 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#[derive(Debug, Clone)]
274pub enum MigrationError {
275 NoPathFound { from: u32, to: u32 },
277 MigrationFailed { from: u32, to: u32, message: String },
279 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#[allow(clippy::wrong_self_convention)]
326pub trait StateMigration {
327 type OldState;
329 type NewState;
331
332 fn from_version(&self) -> u32;
334
335 fn to_version(&self) -> u32;
338
339 fn migrate(&self, old: Self::OldState) -> Result<Self::NewState, String>;
343}
344
345#[allow(clippy::wrong_self_convention)]
349pub trait ErasedMigration<S>: Send + Sync {
350 fn from_version(&self) -> u32;
352 fn to_version(&self) -> u32;
354 fn migrate_erased(
356 &self,
357 old: Box<dyn core::any::Any + Send>,
358 ) -> Result<Box<dyn core::any::Any + Send>, String>;
359}
360
361pub struct MigrationChain<S> {
380 migrations: std::collections::HashMap<u32, Box<dyn ErasedMigration<S>>>,
382}
383
384impl<S: 'static> MigrationChain<S> {
385 #[must_use]
387 pub fn new() -> Self {
388 Self {
389 migrations: std::collections::HashMap::new(),
390 }
391 }
392
393 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 #[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(¤t) {
425 return false;
426 }
427 current += 1;
428 }
429 true
430 }
431
432 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(¤t_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#[derive(Debug)]
486pub enum RestoreResult<S> {
487 Direct(S),
489 Migrated { state: S, from_version: u32 },
491 DefaultFallback { error: MigrationError, default: S },
493}
494
495impl<S> RestoreResult<S> {
496 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 #[must_use]
506 pub fn was_migrated(&self) -> bool {
507 matches!(self, Self::Migrated { .. })
508 }
509
510 #[must_use]
512 pub fn is_fallback(&self) -> bool {
513 matches!(self, Self::DefaultFallback { .. })
514 }
515}
516
517impl<S> VersionedState<S> {
518 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 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 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 #[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 #[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, }
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 #[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 #[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; 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 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 #[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 #[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 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 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 #[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 #[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, }
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)); assert!(chain.has_path(2, 2)); assert!(!chain.has_path(1, 3)); }
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 } 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)); }
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 #[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 assert_ne!(hash(&a), hash(&b));
1109 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 #[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 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 #[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 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 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 #[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 #[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 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 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 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)); }
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 #[derive(Debug, Clone, Default)]
1363 struct ScrollStateV3 {
1364 scroll_offset: u16,
1365 velocity: f32,
1366 smooth_scroll: bool, }
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, }))
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 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 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 #[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 #[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 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 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 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 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 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 #[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); 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); }
1665
1666 #[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}