d9359ba93d
- engine: live.join, live.edit, live.poll, live.peers, live.selection, live.info and live.leave over the live session client (at most 8 rooms, no token in any answer, dropping a session with open rooms returns within 2 s); server.file_meta names the last editor of a file - engine: sync.pending lists the local changes the server does not have yet (new, edited, deleted; at most 500, local comparison only) - ui-egui: a "Waiting to sync" queue sheet opened from the sync sheet, and the "You are offline" banner on the compact Notes page - tests: two sessions converge through the real server using live.* commands; stub-server, queue and banner tests Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
115 lines
5.3 KiB
Rust
115 lines
5.3 KiB
Rust
//! The pending queue (`sync.pending`): the local changes the server does not
|
|
//! have yet, from a scan of the vault against `SyncState.bases`.
|
|
|
|
mod common;
|
|
|
|
use common::{TempVault, seed, temp_vault};
|
|
use notescraft_engine::sync::queue::{ChangeKind, MAX_QUEUE, PendingChange, pending_changes};
|
|
use notescraft_engine::{Registry, Session, SyncState, register_core};
|
|
use notescraft_sync::Base;
|
|
use notescraft_vault::Vault;
|
|
use serde_json::{Value, json};
|
|
|
|
/// A vault in a fresh folder.
|
|
fn vault(name: &str) -> (Vault, TempVault) {
|
|
let kept = temp_vault(name);
|
|
let opened = Vault::open(kept.path()).expect("open the temp vault");
|
|
(opened, kept)
|
|
}
|
|
|
|
/// Records the state the last sync would have left: every pair of
|
|
/// `(path, content)` becomes a base at the hash of its content.
|
|
fn recorded(vault: &Vault, files: &[(&str, &str)]) {
|
|
let mut state = SyncState::default();
|
|
for (path, content) in files {
|
|
state.bases.insert((*path).to_owned(), Base { hash: notescraft_sync::hash_hex(content.as_bytes()), seq: 1 });
|
|
}
|
|
state.save(vault).expect("save the state");
|
|
}
|
|
|
|
/// A vault where a note is new, an other edited, a third deleted (its base
|
|
/// stays recorded) and a fourth untouched.
|
|
fn mixed(name: &str) -> (Vault, TempVault) {
|
|
let (vault, kept) = vault(name);
|
|
seed(kept.path(), &[("new.md", "added here"), ("edited.md", "changed now"), ("same.md", "as synced")]);
|
|
recorded(&vault, &[("edited.md", "was like this"), ("gone.md", "deleted"), ("same.md", "as synced")]);
|
|
let _ = std::fs::remove_file(kept.path().join("gone.md"));
|
|
(vault, kept)
|
|
}
|
|
|
|
/// The paths of the answer, as the tests read them.
|
|
fn paths(changes: &[PendingChange]) -> Vec<(&str, ChangeKind)> {
|
|
changes.iter().map(|change| (change.path.as_str(), change.kind)).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_kinds_follow_the_bases() {
|
|
let (vault, _kept) = mixed("kinds");
|
|
let changes = pending_changes(&vault).expect("pending");
|
|
assert_eq!(paths(&changes), [("edited.md", ChangeKind::Edited), ("gone.md", ChangeKind::Deleted), ("new.md", ChangeKind::Added)], "sorted by path");
|
|
assert_eq!(changes.iter().find(|change| change.path == "new.md").map(|change| change.size), Some("added here".len() as u64), "the size of the file now");
|
|
assert_eq!(changes.iter().find(|change| change.path == "gone.md").map(|change| change.size), Some(0), "a deleted file has no size");
|
|
}
|
|
|
|
#[test]
|
|
fn excluded_folders_are_never_listed() {
|
|
let (vault, kept) = vault("excluded");
|
|
seed(kept.path(), &[("note.md", "real")]);
|
|
std::fs::create_dir_all(kept.path().join(".obsidian")).expect("obsidian");
|
|
std::fs::write(kept.path().join(".obsidian/workspace.json"), "state").expect("obsidian");
|
|
std::fs::create_dir_all(kept.path().join(".notescraft/sync")).expect("notescraft");
|
|
std::fs::write(kept.path().join(".notescraft/sync/kept.json"), "state").expect("notescraft");
|
|
let changes = pending_changes(&vault).expect("pending");
|
|
assert_eq!(paths(&changes), [("note.md", ChangeKind::Added)], "only the note travels");
|
|
}
|
|
|
|
#[test]
|
|
fn the_queue_caps_at_500_entries_in_path_order() {
|
|
let (vault, kept) = vault("cap");
|
|
let names: Vec<(String, String)> = (0..600).map(|index| (format!("note-{index:03}.md"), format!("#{index}\n"))).collect();
|
|
seed(kept.path(), &names.iter().map(|(path, text)| (path.as_str(), text.as_str())).collect::<Vec<_>>()[..]);
|
|
let changes = pending_changes(&vault).expect("pending");
|
|
assert_eq!(changes.len(), MAX_QUEUE);
|
|
assert_eq!(changes.first().map(|change| change.path.as_str()), Some("note-000.md"));
|
|
assert_eq!(changes.last().map(|change| change.path.as_str()), Some("note-499.md"));
|
|
let mut sorted = changes.clone();
|
|
sorted.sort_by(|left, right| left.path.cmp(&right.path));
|
|
assert_eq!(changes, sorted, "sorted by path");
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn an_unreadable_file_is_skipped() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let (vault, kept) = vault("unreadable");
|
|
seed(kept.path(), &[("good.md", "readable"), ("locked.md", "not")]);
|
|
let locked = kept.path().join("locked.md");
|
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("lock the file");
|
|
let changes = pending_changes(&vault).expect("the queue survives the unreadable file");
|
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).expect("unlock");
|
|
assert_eq!(paths(&changes), [("good.md", ChangeKind::Added)], "the unreadable file is left out");
|
|
}
|
|
|
|
#[test]
|
|
fn the_command_answers_the_same_list() {
|
|
let (vault, kept) = vault("command");
|
|
seed(kept.path(), &[("new.md", "added here"), ("edited.md", "changed now")]);
|
|
recorded(&vault, &[("edited.md", "was like this")]);
|
|
drop(vault); // the lock: the session needs it
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register the core commands");
|
|
let mut session = Session::open(kept.path()).expect("open the vault");
|
|
|
|
let answer = registry.run("sync.pending", &mut session, &Value::Null).expect("the command runs");
|
|
assert_eq!(
|
|
answer,
|
|
json!({
|
|
"changes": [
|
|
{ "path": "edited.md", "kind": "edited", "size": "changed now".len() as u64 },
|
|
{ "path": "new.md", "kind": "added", "size": "added here".len() as u64 },
|
|
]
|
|
}),
|
|
"the same list the function returns, as JSON"
|
|
);
|
|
}
|