use galaxy_core::features::FeatureFlag; use galaxy_core::settings::macros::define_settings_group; use galaxy_core::settings::{SupportedPlatforms, SyncToCloud}; use galaxy_core::user_preferences::GetUserPreferences as _; use galaxyui::SingletonEntity; use galaxyui_extras::user_preferences; use instant::Duration; use settings::{PrivatePreferences, PublicPreferences, Setting, SettingsManager}; use settings_value::SettingsValue; use super::{ migrate_native_settings_to_settings_file, needs_settings_file_migration_for_path, SETTINGS_FILE_MIGRATION_COMPLETE_KEY, }; use crate::terminal::session_settings::{NotificationsMode, NotificationsSettings}; // A minimal settings group with one public and one private setting, used to // verify that migration only copies public settings. define_settings_group!(MigrationTestSettings, settings: [ public_setting: PublicSetting { type: bool, default: false, supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Never, private: false, toml_path: "migration_test.public_setting", }, public_string_setting: PublicStringSetting { type: String, default: String::new(), supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Never, private: false, toml_path: "migration_test.public_string_setting", }, private_setting: PrivateSetting { type: bool, default: false, supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Never, private: true, }, ]); /// Registers separate InMemoryPreferences singletons for public and private /// stores, then adds a SettingsManager and the test settings group. fn init_test_app(ctx: &mut galaxyui::AppContext) { ctx.add_singleton_model(move |_| { PublicPreferences::new(Box::::default()) }); ctx.add_singleton_model(move |_| -> PrivatePreferences { PrivatePreferences::new(Box::::default()) }); ctx.add_singleton_model(|_| SettingsManager::default()); MigrationTestSettings::register(ctx); } #[test] fn test_migration_copies_public_settings_from_native_store() { galaxyui::App::test((), |mut app| async move { // Enable the settings file so `preferences_for_setting` routes // public setting writes to the Model singleton (not the private store). let _guard = FeatureFlag::SettingsFile.override_enabled(true); app.update(init_test_app); // Seed the native (private) store with values for both settings. app.update(|ctx| { let native = ctx.private_user_preferences(); native .write_value("PublicSetting", "true".to_owned()) .unwrap(); native .write_value("PrivateSetting", "true".to_owned()) .unwrap(); }); // Before migration, in-memory values should still be defaults (the // public store is empty and registration read from there). app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!(!*settings.public_setting.value()); assert!(!*settings.private_setting.value()); }); // Run the migration. app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // The public setting should now reflect the native store value. app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!( *settings.public_setting.value(), "public setting should have been migrated from native store" ); }); // The public store should now contain the migrated value. app.read(|ctx| { let public = PublicSetting::preferences_for_setting(ctx); let stored = public .read_value_with_hierarchy(PublicSetting::storage_key(), PublicSetting::hierarchy()) .unwrap(); assert_eq!(stored, Some("true".to_owned())); }); // The private setting should NOT have been touched by migration // (it's private, so migration skips it). The in-memory value stays // at default because register() read from the public store (empty). app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!( !*settings.private_setting.value(), "private setting should not be affected by migration" ); }); }); } #[test] fn test_migration_writes_marker_to_native_store() { galaxyui::App::test((), |mut app| async move { app.update(init_test_app); // No marker before migration. app.read(|ctx| { let marker = ctx .private_user_preferences() .read_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY) .unwrap(); assert!(marker.is_none()); }); app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // Marker should now be present. app.read(|ctx| { let marker = ctx .private_user_preferences() .read_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY) .unwrap(); assert!(marker.is_some(), "migration marker should be written"); }); }); } #[test] fn test_migration_skips_settings_absent_from_native_store() { galaxyui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); app.update(init_test_app); // Don't seed anything in the native store — all settings are absent. app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // Settings should remain at defaults. app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!(!*settings.public_setting.value()); assert_eq!(settings.public_string_setting.value().as_str(), ""); }); // The public store should have nothing written. app.read(|ctx| { let public = PublicSetting::preferences_for_setting(ctx); assert!( public .read_value_with_hierarchy( PublicSetting::storage_key(), PublicSetting::hierarchy(), ) .unwrap() .is_none() ); assert!(public .read_value_with_hierarchy( PublicStringSetting::storage_key(), PublicStringSetting::hierarchy(), ) .unwrap() .is_none()); }); }); } #[test] fn test_migration_handles_string_setting() { galaxyui::App::test((), |mut app| async move { app.update(init_test_app); // Seed a JSON-encoded string value in the native store. app.update(|ctx| { let native = ctx.private_user_preferences(); native .write_value("PublicStringSetting", "\"Fira Code\"".to_owned()) .unwrap(); }); app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert_eq!( settings.public_string_setting.value().as_str(), "Fira Code", "string setting should have been migrated" ); }); }); } #[test] fn test_migration_does_not_rerun_when_marker_present() { galaxyui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); let temp_dir = tempfile::tempdir().unwrap(); let settings_file_path = temp_dir.path().join("settings.toml"); app.update(init_test_app); // Seed the native store with a public setting. app.update(|ctx| { let native = ctx.private_user_preferences(); native .write_value("PublicSetting", "true".to_owned()) .unwrap(); }); // Before migration, the guard should allow migration. app.read(|ctx| { assert!( needs_settings_file_migration_for_path(ctx, &settings_file_path), "migration should be needed before first run" ); }); // Run migration. app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // After migration, the marker should prevent re-migration. app.read(|ctx| { assert!( !needs_settings_file_migration_for_path(ctx, &settings_file_path), "migration should not be needed after marker is written" ); }); }); } #[test] fn test_migration_not_needed_when_settings_file_exists() { warpui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); let temp_dir = tempfile::tempdir().unwrap(); let settings_file_path = temp_dir.path().join("settings.toml"); std::fs::write(&settings_file_path, "").unwrap(); app.update(init_test_app); app.read(|ctx| { assert!( !needs_settings_file_migration_for_path(ctx, &settings_file_path), "migration should not be needed when settings.toml exists" ); }); }); } #[test] fn test_migration_with_multiple_setting_types() { galaxyui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); app.update(init_test_app); // Seed the native store with values for all three settings. app.update(|ctx| { let native = ctx.private_user_preferences(); native .write_value("PublicSetting", "true".to_owned()) .unwrap(); native .write_value("PublicStringSetting", "\"Custom Value\"".to_owned()) .unwrap(); native .write_value("PrivateSetting", "true".to_owned()) .unwrap(); }); app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // Both public settings should have been migrated. app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!( *settings.public_setting.value(), "public bool should have been migrated" ); assert_eq!( settings.public_string_setting.value().as_str(), "Custom Value", "public string should have been migrated" ); }); // Public store should contain the migrated bool. app.read(|ctx| { let public = PublicSetting::preferences_for_setting(ctx); assert_eq!( public .read_value_with_hierarchy( PublicSetting::storage_key(), PublicSetting::hierarchy(), ) .unwrap(), Some("true".to_owned()) ); }); // Private setting should NOT have been migrated — in-memory // value stays at default because register() read from the // public store (which was empty for this key). app.read(|ctx| { let settings = MigrationTestSettings::as_ref(ctx); assert!( !*settings.private_setting.value(), "private setting should not be affected by migration" ); }); // The private setting should NOT be in the public store. app.read(|ctx| { let public = PublicSetting::preferences_for_setting(ctx); assert!( public .read_value_with_hierarchy( PrivateSetting::storage_key(), PrivateSetting::hierarchy(), ) .unwrap() .is_none(), "private setting should not appear in public store" ); }); }); } // --------------------------------------------------------------------------- // Tests for serde ↔ file-format mismatch during migration // --------------------------------------------------------------------------- // // NotificationsSettings has #[serde(default)] and contains fields whose serde // and SettingsValue file formats differ: // - NotificationsMode: serde uses PascalCase ("Enabled"), file uses snake_case ("enabled") // - Duration: serde uses {"secs":N,"nanos":N}, file uses a plain integer // // The migration reads serde-format values from the native store and feeds them // through update_setting_with_storage_key, which tries from_file_value first. // If from_file_value silently defaults fields (due to #[serde(default)]), the // serde fallback is never reached and values are lost. mod notifications_migration { use galaxy_core::settings::{SupportedPlatforms, SyncToCloud}; use galaxyui_extras::user_preferences; use settings::{PrivatePreferences, PublicPreferences, Setting, SettingsManager}; use crate::terminal::session_settings::NotificationsSettings; define_settings_group!(NotificationsMigrationTestSettings, settings: [ notifications: MigrationTestNotifications { type: NotificationsSettings, default: NotificationsSettings::default(), supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Never, private: false, toml_path: "migration_test.notifications", max_table_depth: 1, }, ]); pub fn init_notifications_migration_test_app(ctx: &mut galaxyui::AppContext) { ctx.add_singleton_model(move |_| { PublicPreferences::new( Box::::default(), ) }); ctx.add_singleton_model(move |_| -> PrivatePreferences { PrivatePreferences::new( Box::::default(), ) }); ctx.add_singleton_model(|_| SettingsManager::default()); NotificationsMigrationTestSettings::register(ctx); } } use notifications_migration::{ init_notifications_migration_test_app, NotificationsMigrationTestSettings, }; // -- from_file_value unit tests: these demonstrate the derive-level bug ------ #[test] fn test_notifications_from_file_value_rejects_serde_format_enum() { // serde serializes NotificationsMode::Enabled as "Enabled" (PascalCase), // but from_file_value expects "enabled" (snake_case). When the field is // present but unparsable, from_file_value should return None — not // silently fall back to the #[serde(default)] value (Unset). let serde_json_value = serde_json::to_value(NotificationsSettings { mode: NotificationsMode::Enabled, ..NotificationsSettings::default() }) .unwrap(); let result = NotificationsSettings::from_file_value(&serde_json_value); assert!( result.is_none(), "from_file_value should reject serde-format enum values, but got: {result:?}" ); } #[test] fn test_notifications_from_file_value_rejects_serde_format_duration() { // serde serializes Duration as {"secs": N, "nanos": N}, but // Duration::from_file_value expects a plain integer. Use file-format // for mode ("unset") so that the failure is isolated to the Duration field. let json = serde_json::json!({ "mode": "unset", "is_long_running_enabled": true, "long_running_threshold": {"secs": 60, "nanos": 0}, "is_password_prompt_enabled": true, "is_agent_task_completed_enabled": true, "is_needs_attention_enabled": true, "play_notification_sound": true, }); let result = NotificationsSettings::from_file_value(&json); assert!( result.is_none(), "from_file_value should reject serde-format Duration, but got: {result:?}" ); } // -- Migration integration tests: these demonstrate end-to-end data loss ----- #[test] fn test_migration_preserves_notifications_mode() { galaxyui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); app.update(init_notifications_migration_test_app); // Seed the native store with serde-serialized NotificationsSettings // where mode is Enabled. In serde format: {"mode":"Enabled",...}. app.update(|ctx| { let native = ctx.private_user_preferences(); let serde_value = serde_json::to_string(&NotificationsSettings { mode: NotificationsMode::Enabled, ..NotificationsSettings::default() }) .unwrap(); native .write_value("MigrationTestNotifications", serde_value) .unwrap(); }); app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); // Mode should be preserved as Enabled, not silently defaulted to Unset. app.read(|ctx| { let settings = NotificationsMigrationTestSettings::as_ref(ctx); assert_eq!( settings.notifications.value().mode, NotificationsMode::Enabled, "NotificationsMode should be preserved during migration" ); }); }); } #[test] fn test_migration_preserves_custom_long_running_threshold() { galaxyui::App::test((), |mut app| async move { let _guard = FeatureFlag::SettingsFile.override_enabled(true); app.update(init_notifications_migration_test_app); // Seed with a non-default threshold (60s instead of default 30s). // serde serializes Duration as {"secs":60,"nanos":0}, which differs // from the file format (plain integer 60). let custom_threshold = Duration::from_secs(60); app.update(|ctx| { let native = ctx.private_user_preferences(); let serde_value = serde_json::to_string(&NotificationsSettings { long_running_threshold: custom_threshold, ..NotificationsSettings::default() }) .unwrap(); native .write_value("MigrationTestNotifications", serde_value) .unwrap(); }); app.update(|ctx| { migrate_native_settings_to_settings_file(ctx); }); app.read(|ctx| { let settings = NotificationsMigrationTestSettings::as_ref(ctx); assert_eq!( settings.notifications.value().long_running_threshold, custom_threshold, "custom long_running_threshold should be preserved during migration" ); }); }); }