Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

255 lines
14 KiB
Rust

//! Integration tests of the vault catalog: the repair of `vault_catalog`,
//! the launcher rows with their local sync pill, and the `vaults.*`
//! commands over a `Session` with its own settings folder. Nothing here
//! touches a real config file, a real vault's data or the network.
use std::time::{Duration, SystemTime};
use notescraft_engine::{
AppSettings, MAX_CATALOG, MAX_NAME_CHARS, Registry, Session, SyncBadge, VaultMeta, list_rows, load_at, repair_catalog, save_at, touch,
};
use serde_json::{Value, json};
mod common;
use common::{temp_config, temp_vault};
/// The launch budget most callers pass; big enough to never trip a local test.
fn generous() -> std::time::Duration {
std::time::Duration::from_millis(500)
}
/// The string form of a temp vault, a real folder on a real path.
fn path_of(vault: &common::TempVault) -> String {
vault.path().to_string_lossy().into_owned()
}
/// A catalog entry for a test; every field explicit.
fn vault(path: &str, name: Option<&str>, pinned: bool, last_opened_ms: u64) -> VaultMeta {
VaultMeta { path: path.to_owned(), name: name.map(str::to_owned), pinned, last_opened_ms }
}
/// Repair drops the empty paths and the duplicates (the newest copy wins)
/// and clears over-long names without dropping the vault.
#[test]
fn repair_drops_empty_duplicates_and_long_names() {
let long = vault("/long-name", Some("n".repeat(MAX_NAME_CHARS + 1).as_str()), false, 30);
let mut list = vec![
vault("", None, false, 9),
vault("/a", None, false, 1),
vault("/a", None, false, 9),
vault("/a", Some("newest name"), true, 5),
long,
vault("/c", Some(""), false, 2),
];
let notes = repair_catalog(&mut list);
let joined = notes.join("\n");
assert!(joined.contains("kept 3 of 6 entries"), "{joined}");
assert!(joined.contains("cleared the names of 2 entries"), "{joined}");
assert_eq!(list, vec![vault("/long-name", None, false, 30), vault("/a", None, false, 9), vault("/c", None, false, 2)], "{list:?}");
}
/// Past the cap, the pinned vaults and then the newest survive.
#[test]
fn repair_caps_keeping_pinned_then_newest() {
let mut list: Vec<VaultMeta> =
(0..=MAX_CATALOG).map(|index| vault(&format!("/v-{index:03}"), None, false, u64::try_from(index).unwrap_or_default())).collect();
list.push(vault("/pinned-old", None, true, 0));
let notes = repair_catalog(&mut list);
assert!(notes.iter().any(|note| note.contains("kept 200 of 202 entries")), "{notes:?}");
assert_eq!(list.len(), MAX_CATALOG);
assert_eq!(list.first().map(|meta| meta.path.as_str()), Some("/pinned-old"), "the pinned vault survives the cap first");
assert_eq!(list.get(1).map(|meta| meta.last_opened_ms), Some(u64::try_from(MAX_CATALOG).unwrap_or_default()), "then the newest");
assert!(!list.iter().any(|meta| meta.path == "/v-000"), "the oldest entry is gone");
}
/// Pinned vaults first, by name, then the rest, newest first; recents the
/// catalog does not know yet come last with no last-open time.
#[test]
fn rows_order_pinned_then_newest() {
let settings = AppSettings {
vault_catalog: vec![vault("/a", Some("Zz"), false, 10), vault("/b", Some("B"), true, 1), vault("/c", Some("C"), true, 5)],
recent_vaults: vec![String::from("/r")],
..AppSettings::default()
};
let rows = list_rows(&settings, generous());
let want = ["/b", "/c", "/a", "/r"];
let got: Vec<&str> = rows.iter().map(|row| row.path.as_str()).collect();
assert_eq!(got, want, "pinned by name, then newest, then the recents-only path");
assert_eq!(rows.last().map(|row| row.last_opened_ms), Some(0), "the recents-only path has no last-open time");
}
/// The row name: the user rename, else the folder's file name, else the path.
#[test]
fn row_name_prefers_rename_then_folder_name() {
let folder = temp_vault("my-vault");
let path = folder.path().to_string_lossy().into_owned();
let settings = AppSettings {
vault_catalog: vec![vault(&path, Some("My Library"), false, 7), vault("/", None, false, 6), vault("/gone/folder/", None, false, 5)],
..AppSettings::default()
};
let rows = list_rows(&settings, generous());
let by_path = |rows: &[notescraft_engine::VaultRow], path: &str| {
rows.iter().find(|row| row.path == path).map_or_else(|| panic!("no row for {path}"), |row| row.name.clone())
};
assert_eq!(by_path(&rows, &path), "My Library", "the rename wins");
assert_eq!(by_path(&rows, "/"), "/", "a path without a folder name is the name itself");
assert_eq!(by_path(&rows, "/gone/folder/"), "folder", "the folder's file name, even for a missing folder");
}
/// A folder that is not there is marked, not assumed.
#[test]
fn missing_folder_is_marked_not_existing() {
let folder = temp_vault("exists");
let path = folder.path().to_string_lossy().into_owned();
let settings = AppSettings { vault_catalog: vec![vault("/gone", None, false, 3), vault(&path, None, false, 2)], ..AppSettings::default() };
let rows = list_rows(&settings, generous());
let by_path = |path: &str| rows.iter().find(|row| row.path == path).map(|row| (row.exists, row.sync.clone()));
assert_eq!(by_path("/gone"), Some((false, SyncBadge::NotSynced)), "a missing folder is not there and cannot have a pill");
assert_eq!(by_path(&path), Some((true, SyncBadge::NotSynced)), "the folder exists, so it was checked");
}
/// A used-up budget stops the I/O but every row still comes back; with a
/// budget of zero at most the first row is checked.
#[test]
fn budget_stops_checking_but_returns_every_row() {
let folder = temp_vault("real");
let path = folder.path().to_string_lossy().into_owned();
let settings = AppSettings {
vault_catalog: vec![vault(&path, None, false, 2), vault("/missing-a", None, false, 1), vault("/missing-b", None, false, 0)],
..AppSettings::default()
};
let rows = list_rows(&settings, Duration::ZERO);
assert_eq!(rows.len(), 3, "every row comes back");
let lives: Vec<(&str, bool)> = rows.iter().map(|row| (row.path.as_str(), row.exists)).collect();
assert_eq!(lives, vec![(path.as_str(), true), ("/missing-a", true), ("/missing-b", true)], "the first is checked, the rest is assumed");
}
/// The badge comes from the local sync files only: no binding, a binding, a
/// malformed binding, a pending count and an oversized junk binding.
#[test]
fn sync_badge_from_local_files() {
let plain = temp_vault("pill-plain");
let bound = temp_vault("pill-bound");
let broken = temp_vault("pill-broken");
let waiting = temp_vault("pill-waiting");
let big = temp_vault("pill-big");
let sync_dir = |vault: &common::TempVault| vault.path().join(".notescraft").join("sync");
std::fs::create_dir_all(sync_dir(&bound)).expect("sync dir");
std::fs::create_dir_all(sync_dir(&broken)).expect("sync dir");
std::fs::create_dir_all(sync_dir(&waiting)).expect("sync dir");
std::fs::create_dir_all(sync_dir(&big)).expect("sync dir");
let binding = r#"{"server":"https://sync.example.net","vault_id":"v-one","role":"owner"}"#;
std::fs::write(sync_dir(&bound).join("binding.json"), binding).expect("binding");
std::fs::write(sync_dir(&broken).join("binding.json"), r#"{"server": 3}"#).expect("malformed binding");
std::fs::write(sync_dir(&waiting).join("binding.json"), binding).expect("binding");
std::fs::write(sync_dir(&waiting).join("pending.json"), r#"{"count":3}"#).expect("pending");
std::fs::write(sync_dir(&big).join("binding.json"), "x".repeat(1024 * 1024)).expect("1 MiB of junk");
let settings = AppSettings {
vault_catalog: vec![
vault(&path_of(&plain), None, false, 5),
vault(&path_of(&bound), None, false, 4),
vault(&path_of(&broken), None, false, 3),
vault(&path_of(&waiting), None, false, 2),
vault(&path_of(&big), None, false, 1),
],
..AppSettings::default()
};
let rows = list_rows(&settings, generous());
let pill = |vault: &common::TempVault| {
let row = rows.iter().find(|row| row.path == path_of(vault)).unwrap_or_else(|| panic!("no row for {}", path_of(vault)));
(row.sync.clone(), row.server.clone(), row.role.clone())
};
assert_eq!(pill(&plain), (SyncBadge::NotSynced, None, None), "no binding, no pill");
assert_eq!(pill(&bound), (SyncBadge::Synced, Some(String::from("sync.example.net")), Some(String::from("owner"))));
assert_eq!(pill(&broken), (SyncBadge::NotSynced, None, None), "a malformed binding is not usable");
assert_eq!(pill(&waiting), (SyncBadge::Waiting(3), Some(String::from("sync.example.net")), Some(String::from("owner"))));
assert_eq!(pill(&big), (SyncBadge::NotSynced, None, None), "a 1 MiB binding is ignored, not read in full");
}
/// `vaults.pin`, `vaults.rename` and `vaults.remove` store their changes, and
/// `vaults.list` shows them; `remove` forgets in the catalog and the recents.
#[test]
fn commands_pin_rename_remove_roundtrip() {
let vault = temp_vault("commands-vault");
let path = path_of(&vault);
let config = temp_config("commands-cfg");
let mut session = Session::open(vault.path()).expect("open the session");
session.use_settings_dir(config.path());
let mut registry = Registry::new();
notescraft_engine::register_core(&mut registry).expect("register the core commands");
let run = |session: &mut Session, id: &str, params: Value| registry.run(id, session, &params).unwrap_or_else(|error| panic!("{id}: {error}"));
// Unknown vault: pinning remembers it, first on the list.
run(&mut session, "vaults.pin", json!({ "path": path, "pinned": true }));
let list = run(&mut session, "vaults.list", json!({}));
let rows = list.get("vaults").and_then(Value::as_array).unwrap_or_else(|| panic!("no vaults in {list}"));
assert_eq!(rows.len(), 1, "{list}");
assert_eq!(rows.first().and_then(|row| row.get("pinned")).and_then(Value::as_bool), Some(true), "{list}");
let folder_name = vault.path().file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
assert_eq!(rows.first().and_then(|row| row.get("name")).cloned(), Some(json!(folder_name)), "the folder's file name");
// Rename, clear with an empty name, refuse an over-long one.
run(&mut session, "vaults.rename", json!({ "path": path, "name": "Big Work" }));
let (settings, notes) = load_at(config.path());
assert_eq!(notes, &[] as &[String], "{notes:?}");
assert_eq!(settings.vault_catalog.first().map(|meta| meta.name.clone()), Some(Some(String::from("Big Work"))), "{settings:?}");
assert_eq!(settings.recent_vaults, Vec::<String>::new(), "pinning did not invent a recents entry");
let refused = registry.run("vaults.rename", &mut session, &json!({ "path": path, "name": "x".repeat(MAX_NAME_CHARS + 1) }));
let error = refused.err().unwrap_or_else(|| panic!("the over-long name was accepted"));
assert!(error.to_string().contains("longer than"), "{error}");
run(&mut session, "vaults.rename", json!({ "path": path, "name": "" }));
let (cleared, _) = load_at(config.path());
assert_eq!(cleared.vault_catalog.first().map(|meta| meta.name.clone()), Some(None), "the empty name cleared the rename");
// Remove forgets in both lists and never touches the folder.
let settings = AppSettings { recent_vaults: vec![path.clone()], ..settings };
save_at(config.path(), &settings).expect("seed the recents");
run(&mut session, "vaults.remove", json!({ "path": path }));
let (after, _) = load_at(config.path());
assert_eq!(after.vault_catalog, Vec::<VaultMeta>::new(), "{after:?}");
assert_eq!(after.recent_vaults, Vec::<String>::new(), "{after:?}");
assert!(vault.path().is_dir(), "the folder is untouched");
}
/// The `vault_catalog` list survives the settings store round trip.
#[test]
fn settings_survive_save_and_load() {
let config = temp_config("catalog-roundtrip");
let now = u64::try_from(SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).ok().map(|time| time.as_millis()).unwrap_or_default()).unwrap_or_default();
let settings = AppSettings {
recent_vaults: vec![String::from("/vault-one")],
vault_catalog: vec![vault("/vault-one", Some("One"), true, now), vault("/vault-two", None, false, now.saturating_sub(1))],
..AppSettings::default()
};
save_at(config.path(), &settings).expect("save the catalog");
let (loaded, notes) = load_at(config.path());
assert_eq!(notes, &[] as &[String], "{notes:?}");
assert_eq!(loaded, settings, "the whole document, catalog included");
// `touch` adds the second vault to the catalog and moves it to the recents front.
let mut settings =
AppSettings { vault_catalog: vec![vault("/vault-one", None, false, now)], recent_vaults: vec![String::from("/vault-one")], ..AppSettings::default() };
touch(&mut settings, "/vault-two", now.checked_add(5).unwrap_or_default());
assert_eq!(settings.recent_vaults.first().map(String::as_str), Some("/vault-two"), "the new vault leads the recents");
assert_eq!(settings.recent_vaults.len(), 2, "the old recents stay");
assert!(settings.vault_catalog.iter().any(|meta| meta.path == "/vault-two" && meta.last_opened_ms == now.checked_add(5).unwrap_or_default()));
}
/// A `config.toml` from before the field existed loads with an empty catalog.
#[test]
fn old_settings_file_without_the_field_still_loads() {
let config = temp_config("catalog-old-file");
std::fs::write(config.path().join("config.toml"), b"schema_version = 1\n\nrecent_vaults = [\"/old-vault\"]\n\n[session]\ndaily_folder = \"Notes\"\n")
.expect("write the old file");
let (settings, notes) = load_at(config.path());
assert_eq!(settings.vault_catalog, Vec::<VaultMeta>::new(), "no catalog, no problem");
assert_eq!(settings.recent_vaults, [String::from("/old-vault")], "the old keys still load");
assert_eq!(notes, &[] as &[String], "{notes:?}");
// And the next save adds the field harmlessly.
save_at(config.path(), &settings).expect("save");
let (again, notes) = load_at(config.path());
assert_eq!(again, settings);
assert_eq!(notes, &[] as &[String], "{notes:?}");
}