Files
2026-10-14 23:16:33 -03:00

201 lines
9.4 KiB
Rust

//! Integration tests of the settings document: the `craft-settings` store
//! round trip, recovery of a damaged file, newer schema files, every repair
//! (SPEC 3, 4.1, 6 item 14). The commands are covered in
//! [`settings_commands`](super); both run against a throw-away `config.toml`
//! folder the session points at, so nothing here touches a real config or a
//! real vault.
use notescraft_engine::{AppSettings, Error, SCHEMA_VERSION};
use common::{TempVault, temp_config};
mod common;
/// A temp vault with its own settings folder to load and save against.
struct Fix {
config: TempVault,
}
impl Fix {
#[must_use]
fn new(name: &str) -> Self {
Self { config: temp_config(name) }
}
}
/// The `config.toml` inside the settings folder.
fn config_file(fix: &Fix) -> std::path::PathBuf {
fix.config.path().join("config.toml")
}
/// (settings, notes) from the settings folder of `fix`.
fn load_from(config: &TempVault) -> (AppSettings, Vec<String>) {
notescraft_engine::load_at(config.path())
}
/// Defaults survive the TOML round trip with no notes at all.
#[test]
fn defaults_round_trip() {
let fix = Fix::new("round-trip");
let before = AppSettings::default();
notescraft_engine::save_at(fix.config.path(), &before).expect("save the defaults");
assert!(config_file(&fix).exists(), "the store writes the file even for defaults");
let (settings, notes) = load_from(&fix.config);
assert_eq!(settings, before);
assert_eq!(notes, &[] as &[String], "{notes:?}");
}
/// A damaged `config.toml` is moved aside, defaults are in use and the note
/// says where the bytes went.
#[test]
fn a_corrupt_file_is_recovered_with_a_note() {
let fix = Fix::new("corrupt");
let broken = "this is = not toml ][";
std::fs::write(config_file(&fix), broken).expect("write the broken file");
let (settings, notes) = load_from(&fix.config);
assert_eq!(settings, AppSettings::default());
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].starts_with("the damaged config.toml was kept at "), "{}", notes[0]);
assert!(notes[0].contains("and the defaults are in use"), "{}", notes[0]);
let kept: Vec<_> = std::fs::read_dir(fix.config.path())
.unwrap_or_else(|error| panic!("read the config dir: {error}"))
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
assert!(kept.iter().any(|name| name.starts_with("config.broken-")), "kept = {kept:?}, the corrupt bytes are kept");
}
/// A new `schema_version` file from a newer build is read (its known sections
/// are in use) but never overwritten.
#[test]
fn a_newer_schema_file_is_read_but_not_saved() {
let fix = Fix::new("newer");
std::fs::write(config_file(&fix), b"schema_version = 2\n\n[session]\ndaily_folder = \"Notes\"\n\n[hotkeys]\n\"note.read\" = \"Mod-r\"\n")
.expect("write the newer file");
let (settings, notes) = load_from(&fix.config);
assert_eq!(settings.session.daily_folder, "Notes", "known keys are still usable");
assert_eq!(settings.hotkeys.get("note.read").map(String::as_str), Some("Mod-r"));
let joined = notes.join("\n");
assert!(joined.contains("config.toml is read-only: config.toml has schema version 2"), "{joined}");
let refused = notescraft_engine::save_at(fix.config.path(), &AppSettings::default());
assert!(matches!(refused, Err(Error::Settings(ref message)) if message.contains("read-only")), "{refused:?}");
let text = std::fs::read_to_string(config_file(&fix)).unwrap_or_default();
assert!(text.starts_with("schema_version = 2"), "the newer file is untouched: {text}");
}
/// A file one version older runs the (empty) migration hook per step and gets
/// the migration note.
#[test]
fn an_older_schema_file_migrates() {
let fix = Fix::new("migrate");
std::fs::write(config_file(&fix), b"schema_version = 0\n\n[session]\ntheme = \"latte\"\n").expect("write the old file");
let (settings, notes) = load_from(&fix.config);
assert_eq!(settings.session.theme, "latte");
assert!(notes.iter().any(|note| note.contains("config.toml was migrated from schema version 0")), "{notes:?}");
// The migrated document is written back at the current version, too.
notescraft_engine::save_at(fix.config.path(), &settings).expect("the migrated file can be saved over");
let text = std::fs::read_to_string(config_file(&fix)).unwrap_or_default();
assert!(text.contains(&format!("schema_version = {SCHEMA_VERSION}")), "{text}");
}
/// A section that parses as TOML but does not fit the document is replaced by
/// its defaults and reported, while the other sections stay.
#[test]
fn a_broken_section_is_reset_with_a_note() {
let fix = Fix::new("broken-section");
std::fs::write(config_file(&fix), b"schema_version = 1\n\nhotkeys = \"oops\"\n\n[session]\ntheme = \"latte\"\n").expect("write");
let (settings, notes) = load_from(&fix.config);
assert_eq!(settings.hotkeys, std::collections::BTreeMap::new(), "the broken section is at its defaults");
assert_eq!(settings.session.theme, "latte", "the other sections survive");
assert!(notes.iter().any(|note| note.starts_with("the `hotkeys` section was replaced by its defaults")), "{notes:?}");
}
/// A hand-written file with bad values is repaired on load, the notes say
/// what was repaired, and the next save stores the repaired document.
#[test]
fn bad_values_in_a_file_are_repaired_with_notes() {
let fix = Fix::new("repairs");
let big = "x".repeat(notescraft_engine::MAX_UI_BYTES * 2);
let document = format!(
"schema_version = 1\n\nui = {{ theme = \"latte\", blob = \"{big}\" }}\n\nrecent_vaults = [\"b\", \"b\", \"\", \"a\"]\n\n[session]\ndaily_folder = \"../up\"\n\n[hotkeys]\n\"bad key\" = \"Mod-r\"\n\"ok\" = \"not ok!!\"\n"
);
std::fs::write(config_file(&fix), document).expect("write the file with repairs");
let (settings, notes) = load_from(&fix.config);
let joined = notes.join("\n");
assert!(joined.contains("session.daily_folder"), "{joined}");
assert!(joined.contains("ui: the opaque ui state is dropped"), "{joined}");
assert!(joined.contains("recent_vaults: kept"), "{joined}");
assert!(joined.contains("hotkeys: dropped"), "{joined}");
assert_eq!(settings.session.daily_folder, "Daily");
assert_eq!(settings.ui, AppSettings::default().ui);
assert_eq!(settings.recent_vaults, ["b", "a"].iter().map(|s| (*s).to_owned()).collect::<Vec<String>>());
assert_eq!(settings.hotkeys, std::collections::BTreeMap::new());
notescraft_engine::save_at(fix.config.path(), &settings).expect("save");
let (again, again_notes) = load_from(&fix.config);
assert_eq!(again, settings, "the repaired values stay over a save");
// The raw keys craft-settings preserves (hand edits survive a merge) are
// still in the file, so they are repaired again, with the same notes.
let wanted: Vec<&String> = again_notes.iter().filter(|note| note.starts_with("ui:") || note.starts_with("hotkeys:")).collect();
assert_eq!(wanted.len(), 2, "{again_notes:?}");
}
/// The recents list keeps its order through the file round trip.
#[test]
fn recents_keep_their_order_through_the_file() {
let fix = Fix::new("recents");
let settings = AppSettings { recent_vaults: ["zed", "old", "new"].iter().map(|s| (*s).to_owned()).collect(), ..AppSettings::default() };
notescraft_engine::save_at(fix.config.path(), &settings).expect("save");
let (loaded, notes) = load_from(&fix.config);
assert_eq!(notes, &[] as &[String], "{notes:?}");
assert_eq!(loaded.recent_vaults, settings.recent_vaults, "newest first, in order");
}
/// The attachment cap lives in the nested `session.files` group and is set and
/// repaired like every other session setting (SPEC 9).
#[test]
fn the_attachment_cap_is_a_session_files_setting() {
use notescraft_engine::{FilesConfig, Session, SessionConfig};
let vault = temp_config("attach-cap-setting");
let mut session = Session::open(vault.path()).expect("open");
session.use_settings_dir(temp_config("attach-cap-cfg").path());
assert_eq!(session.config().files.max_attachment_mib, 64, "the default");
let mut registry = notescraft_engine::Registry::new();
notescraft_engine::register_core(&mut registry).expect("register");
let set = |session: &mut Session, key: &str, value: i64| {
let params = serde_json::json!({ "key": key, "value": value });
registry.run("settings.set", session, &params)
};
let ok = set(&mut session, "session.files.max_attachment_mib", 128);
assert!(ok.is_ok(), "{ok:?}");
assert_eq!(session.config().files.max_attachment_mib, 128, "applied to the live session");
// 0 is refused at the config boundary...
let config = SessionConfig { files: FilesConfig { max_attachment_mib: 0 }, ..SessionConfig::default() };
assert!(config.validate().is_err(), "0 is refused");
// ...and repaired to the default with a note through the settings document.
let repaired = set(&mut session, "session.files.max_attachment_mib", 0);
assert!(repaired.is_ok(), "{repaired:?}");
assert_eq!(session.config().files.max_attachment_mib, 64, "repaired to the default");
let notes = repaired
.unwrap_or_default()
.get("notes")
.and_then(serde_json::Value::as_array)
.map(|notes| notes.iter().filter_map(|note| note.as_str()).collect::<Vec<&str>>().join("; "))
.unwrap_or_default();
assert!(notes.contains("session.files.max_attachment_mib"), "{notes}");
}