Files
Marcos Paulo d9359ba93d
ci / ci (windows-latest) (push) Has been cancelled
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
feat: live.* engine commands, last-edit lookup, waiting-to-sync queue and offline banner
- 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>
2026-10-20 13:00:13 -03:00

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"
);
}