Files
galaxy/app/src/settings/init_tests.rs
T

531 lines
19 KiB
Rust

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::<user_preferences::in_memory::InMemoryPreferences>::default())
});
ctx.add_singleton_model(move |_| -> PrivatePreferences {
PrivatePreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::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::<user_preferences::in_memory::InMemoryPreferences>::default(),
)
});
ctx.add_singleton_model(move |_| -> PrivatePreferences {
PrivatePreferences::new(
Box::<user_preferences::in_memory::InMemoryPreferences>::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"
);
});
});
}