mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
220 lines
9.4 KiB
Rust
220 lines
9.4 KiB
Rust
mod common;
|
|
|
|
use common::{Theme, config_path, open_in, read_config, write_config};
|
|
use craft_settings::{ReadOnlyReason, SettingsError, Source};
|
|
|
|
#[test]
|
|
fn syntax_error_moves_the_file_aside_and_uses_defaults() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"de\"\n");
|
|
let (mut store, report) = open_in(tmp.path());
|
|
assert_eq!(report.source, Source::Defaults);
|
|
assert!(!store.is_read_only());
|
|
let moved = report.broken_moved_to.clone().expect("moved aside");
|
|
assert!(moved.file_name().unwrap().to_str().unwrap().starts_with("config.broken-"), "{moved:?}");
|
|
assert!(moved.exists());
|
|
assert!(!config_path(tmp.path()).exists());
|
|
assert_eq!(store.get().general.language, "en");
|
|
|
|
store.flush().unwrap();
|
|
assert!(moved.exists(), "the damaged copy is never deleted");
|
|
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn two_broken_files_get_different_names() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_config(tmp.path(), "not = [toml");
|
|
let (_, first) = open_in(tmp.path());
|
|
write_config(tmp.path(), "still = [broken");
|
|
let (_, second) = open_in(tmp.path());
|
|
assert_ne!(first.broken_moved_to, second.broken_moved_to);
|
|
assert!(second.broken_moved_to.unwrap().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn bad_value_only_resets_its_section() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"de\"\n\n[appearance]\ntheme = \"sepia\"\nui_scale = 2.0\n");
|
|
let (store, report) = open_in(tmp.path());
|
|
assert_eq!(report.source, Source::File);
|
|
assert!(report.broken_moved_to.is_none());
|
|
assert!(!store.is_read_only());
|
|
assert_eq!(report.section_errors.len(), 1, "{report:?}");
|
|
assert_eq!(report.section_errors[0].section, "appearance");
|
|
assert_eq!(store.get().general.language, "de", "the good section survives");
|
|
assert_eq!(store.get().appearance.theme, Theme::Light, "the bad section falls back to defaults");
|
|
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
|
|
}
|
|
|
|
#[test]
|
|
fn many_unknown_keys_load_in_linear_time() {
|
|
// Each key was once tried against every key accepted before it.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut text = String::from("schema_version = 2\n");
|
|
for i in 0..20_000 {
|
|
text.push_str(&format!("unknown_{i} = \"{}\"\n", "x".repeat(64)));
|
|
}
|
|
text.push_str("[general]\nlanguage = \"de\"\n");
|
|
write_config(tmp.path(), &text);
|
|
let started = std::time::Instant::now();
|
|
let (store, report) = open_in(tmp.path());
|
|
assert!(report.section_errors.is_empty(), "{report:?}");
|
|
assert_eq!(store.get().general.language, "de");
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
|
}
|
|
|
|
#[test]
|
|
fn newer_schema_opens_read_only_and_never_overwrites() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let original = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
|
|
write_config(tmp.path(), original);
|
|
let (mut store, report) = open_in(tmp.path());
|
|
assert!(matches!(report.read_only, Some(ReadOnlyReason::NewerSchema { file: 9, app: 2 })), "{report:?}");
|
|
assert_eq!(store.get().general.language, "de", "values are still shown");
|
|
store.update(|p| p.general.language = "fr".into());
|
|
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
|
assert_eq!(read_config(tmp.path()), original);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_migration_opens_read_only_and_leaves_the_file() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let original = "schema_version = 0\n[general]\nlanguage = \"de\"\n";
|
|
write_config(tmp.path(), original);
|
|
let (mut store, report) = open_in(tmp.path());
|
|
assert!(matches!(report.read_only, Some(ReadOnlyReason::Migration(_))), "{report:?}");
|
|
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
|
assert_eq!(read_config(tmp.path()), original);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn damaged_file_that_cannot_be_moved_is_left_alone() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_config(tmp.path(), "not = [toml");
|
|
let dir = tmp.path();
|
|
if !common::restrict_directory_writes(dir) {
|
|
return;
|
|
}
|
|
let (mut store, report) = open_in(dir);
|
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
assert!(store.is_read_only(), "{report:?}");
|
|
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
|
assert_eq!(read_config(dir), "not = [toml");
|
|
}
|
|
|
|
// --- Review on #22: the backup must never be replaced by a damaged or emptied file. ---
|
|
|
|
#[test]
|
|
fn file_damaged_while_running_is_moved_aside_and_backup_kept() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (mut store, _) = open_in(tmp.path());
|
|
store.update(|p| p.general.language = "fr".into());
|
|
store.flush().unwrap();
|
|
store.update(|p| p.general.language = "es".into());
|
|
store.flush().unwrap();
|
|
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
|
|
assert!(good_backup.contains("language = \"fr\""));
|
|
|
|
// A hand edit with a typo while the app is running.
|
|
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"it\"\n");
|
|
store.update(|p| p.general.language = "pt".into());
|
|
store.flush().unwrap();
|
|
|
|
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup, "backup untouched");
|
|
let moved = store.take_moved_aside();
|
|
assert_eq!(moved.len(), 1);
|
|
assert_eq!(std::fs::read_to_string(&moved[0]).unwrap(), "schema_version = 2\n[general\nlanguage = \"it\"\n", "the user's edit is kept");
|
|
assert!(read_config(tmp.path()).contains("language = \"pt\""));
|
|
assert!(store.take_moved_aside().is_empty(), "drained");
|
|
}
|
|
|
|
#[test]
|
|
fn emptied_file_does_not_replace_the_backup() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (mut store, _) = open_in(tmp.path());
|
|
store.update(|p| p.general.language = "fr".into());
|
|
store.flush().unwrap();
|
|
store.update(|p| p.general.language = "es".into());
|
|
store.flush().unwrap();
|
|
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
|
|
|
|
write_config(tmp.path(), "");
|
|
store.update(|p| p.general.language = "pt".into());
|
|
store.flush().unwrap();
|
|
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup);
|
|
assert!(read_config(tmp.path()).contains("language = \"pt\""));
|
|
}
|
|
|
|
#[test]
|
|
fn newer_build_writing_while_running_is_not_overwritten() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (mut store, _) = open_in(tmp.path());
|
|
store.flush().unwrap();
|
|
let newer = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
|
|
write_config(tmp.path(), newer);
|
|
store.update(|p| p.general.language = "fr".into());
|
|
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
|
assert!(store.is_read_only());
|
|
assert_eq!(read_config(tmp.path()), newer);
|
|
}
|
|
|
|
// --- Review on #22: a failed migration must not leave half-migrated values in memory. ---
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn failed_migration_in_an_unwritable_folder_shows_defaults() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
// Version 0 has no migration step, so upgrading fails.
|
|
write_config(tmp.path(), "schema_version = 0\n[general]\nlanguage = \"de\"\n");
|
|
if !common::restrict_directory_writes(tmp.path()) {
|
|
return;
|
|
}
|
|
let (store, report) = open_in(tmp.path());
|
|
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
assert!(matches!(report.read_only, Some(ReadOnlyReason::DirNotWritable(_))), "{report:?}");
|
|
assert_eq!(store.get().general.language, "en", "defaults, not half-migrated values");
|
|
assert_eq!(report.source, Source::Defaults);
|
|
}
|
|
|
|
// --- Review on #22 (skylartaylor): an outside file whose migration fails must not be overwritten. ---
|
|
|
|
#[test]
|
|
fn outside_file_with_failing_migration_is_not_overwritten() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (mut store, _) = open_in(tmp.path());
|
|
store.flush().unwrap();
|
|
// Another writer leaves a file at a version this build cannot migrate (0 has no step).
|
|
let external = "schema_version = 0\n[general]\nlanguage = 'external'\n";
|
|
write_config(tmp.path(), external);
|
|
|
|
store.update(|p| p.general.language = "local".into());
|
|
let result = store.flush();
|
|
assert!(matches!(result, Err(SettingsError::ReadOnly(_))), "{result:?}");
|
|
assert!(store.is_read_only());
|
|
assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");
|
|
assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only");
|
|
}
|
|
|
|
#[test]
|
|
fn an_emptied_file_is_not_adopted_over_the_loaded_document() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_config(tmp.path(), "schema_version = 2\n\n# my note\n[plugins]\nenabled = true\n\n[general]\nlanguage = \"de\"\n");
|
|
let (mut store, _) = open_in(tmp.path());
|
|
store.update(|p| p.general.language = "fr".into());
|
|
store.flush().unwrap();
|
|
|
|
// Emptied while the app runs: an editor crash, a sync tool, a hand `truncate`.
|
|
write_config(tmp.path(), "");
|
|
store.update(|p| p.general.language = "es".into());
|
|
store.flush().unwrap();
|
|
|
|
let text = read_config(tmp.path());
|
|
assert!(text.contains("# my note"), "{text}");
|
|
assert!(text.contains("[plugins]"), "{text}");
|
|
assert!(text.contains("language = \"es\""), "{text}");
|
|
}
|