448 lines
19 KiB
Rust
448 lines
19 KiB
Rust
//! The sync sheet driven the way a user does: real frames at 390 x 844 over a
|
||
//! vault bound to a stub server (design `03-sync-sheet`).
|
||
|
||
use egui::{Context, Event, Key, PointerButton, Pos2, RawInput};
|
||
use notescraft_engine::{Session, Timings, bind_folder};
|
||
use notescraft_ui_egui::app::NotesApp;
|
||
use std::io::{BufRead, BufReader, Read, Write};
|
||
use std::net::{TcpListener, TcpStream};
|
||
use std::path::Path;
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
/// The window of a phone.
|
||
const PHONE: egui::Vec2 = egui::vec2(390.0, 844.0);
|
||
/// The window of a desktop.
|
||
const DESKTOP: egui::Vec2 = egui::vec2(1280.0, 800.0);
|
||
/// The token of the bound vault; it must never reach the screen.
|
||
const TOKEN: &str = "secret-token";
|
||
/// Longest the tests wait for a stub request or an idle worker.
|
||
const WAIT: Duration = Duration::from_secs(5);
|
||
|
||
/// One request the stub received.
|
||
#[derive(Debug, Clone)]
|
||
struct Seen {
|
||
/// The HTTP method.
|
||
method: String,
|
||
/// The path of the request line, without the query.
|
||
path: String,
|
||
}
|
||
|
||
/// A stub server: canned answers per path and method, and every request it saw.
|
||
struct Stub {
|
||
base: String,
|
||
seen: Arc<Mutex<Vec<Seen>>>,
|
||
stop: Arc<AtomicBool>,
|
||
}
|
||
|
||
impl Stub {
|
||
/// Starts a stub on an ephemeral port. `devices` is the body of the device
|
||
/// list; `/changes` answers an empty log and `/files/a.md` answers applied,
|
||
/// so the worker's cycle lands on "Synced".
|
||
fn spawn(devices: &str) -> Self {
|
||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
|
||
let port = listener.local_addr().expect("addr").port();
|
||
let record = r#"{"path":"a.md","hash":"x","size":2,"mtime_ms":0,"seq":1,"deleted":false}"#;
|
||
let empty = r#"{"head":0,"records":[],"more":false}"#;
|
||
let answer = |status: &str, body: &str| format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||
let answers: Vec<(String, String, String)> = vec![
|
||
("GET".to_owned(), "/changes".to_owned(), answer("200 OK", empty)),
|
||
("PUT".to_owned(), "/files/a.md".to_owned(), answer("201 Created", record)),
|
||
("GET".to_owned(), "/devices".to_owned(), answer("200 OK", devices)),
|
||
];
|
||
let seen: Arc<Mutex<Vec<Seen>>> = Arc::new(Mutex::new(Vec::new()));
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let (thread_seen, thread_stop) = (Arc::clone(&seen), Arc::clone(&stop));
|
||
std::thread::spawn(move || {
|
||
while !thread_stop.load(Ordering::Relaxed) {
|
||
match listener.accept() {
|
||
Ok((stream, _)) => {
|
||
// One thread per connection: the client may keep the connection
|
||
// open, and the listener must go on accepting.
|
||
let Ok(mut writer) = stream.try_clone() else { continue };
|
||
let mut reader = BufReader::new(stream);
|
||
let answers = Arc::new(answers.clone());
|
||
let seen = Arc::clone(&thread_seen);
|
||
std::thread::spawn(move || {
|
||
while let Some(request) = read_request(&mut reader) {
|
||
seen.lock().expect("seen").push(request.clone());
|
||
let hit = answers.iter().find(|(method, path, _)| *method == request.method && request.path.contains(path.as_str()));
|
||
let canned = hit.map_or("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n", |(_, _, canned)| canned.as_str());
|
||
let _ = writer.write_all(canned.as_bytes());
|
||
let _ = writer.flush();
|
||
}
|
||
});
|
||
}
|
||
Err(_) => std::thread::sleep(Duration::from_millis(2)),
|
||
}
|
||
}
|
||
});
|
||
Self { base: format!("http://127.0.0.1:{port}"), seen, stop }
|
||
}
|
||
|
||
/// How many times the change log was polled.
|
||
fn change_polls(&self) -> u32 {
|
||
self.seen.lock().expect("seen").iter().filter(|seen| seen.method == "GET" && seen.path.ends_with("/changes")).count().try_into().unwrap_or(u32::MAX)
|
||
}
|
||
|
||
/// The requests the stub saw.
|
||
fn requests(&self) -> Vec<Seen> {
|
||
self.seen.lock().expect("seen").clone()
|
||
}
|
||
|
||
/// Whether the stub saw a write to `path`.
|
||
fn uploaded(&self, path: &str) -> bool {
|
||
self.requests().iter().any(|seen| seen.method == "PUT" && seen.path.ends_with(path))
|
||
}
|
||
|
||
/// Waits, bounded, until `wanted` holds.
|
||
fn wait_for(&self, wanted: impl Fn(&[Seen]) -> bool) {
|
||
let deadline = Instant::now() + WAIT;
|
||
while Instant::now() < deadline && !wanted(&self.requests()) {
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for Stub {
|
||
fn drop(&mut self) {
|
||
self.stop.store(true, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
/// Reads one request: method, path, and its body skipped; the request ends when
|
||
/// the client closes the connection.
|
||
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Seen> {
|
||
let mut line = String::new();
|
||
reader.read_line(&mut line).ok()?;
|
||
let mut parts = line.split_whitespace();
|
||
let method = parts.next()?.to_owned();
|
||
let path = parts.next()?.to_owned();
|
||
let mut length = 0_usize;
|
||
loop {
|
||
let mut header = String::new();
|
||
if reader.read_line(&mut header).ok()? == 0 {
|
||
break;
|
||
}
|
||
if header.trim_end().is_empty() {
|
||
break;
|
||
}
|
||
if let Some((name, value)) = header.trim_end().split_once(':')
|
||
&& name.trim().eq_ignore_ascii_case("content-length")
|
||
{
|
||
length = value.trim().parse().unwrap_or(0);
|
||
}
|
||
}
|
||
let mut body = vec![0_u8; length];
|
||
if length > 0 {
|
||
reader.read_exact(&mut body).ok()?;
|
||
}
|
||
Some(Seen { method, path: path.split('?').next().unwrap_or_default().to_owned() })
|
||
}
|
||
|
||
struct Driver {
|
||
app: NotesApp,
|
||
ctx: Context,
|
||
time: f64,
|
||
size: egui::Vec2,
|
||
}
|
||
|
||
/// A painted text: what it says, where it starts and its galley.
|
||
#[derive(Debug)]
|
||
struct Painted {
|
||
text: String,
|
||
pos: Pos2,
|
||
galley: Arc<egui::Galley>,
|
||
}
|
||
|
||
/// An app over a fresh vault bound to `stub`, with a note open; the settings
|
||
/// dir lives beside the vault, never inside it (the vault is the sync scope).
|
||
fn driven(stub: &Stub, files: &[(&str, &str)], size: egui::Vec2) -> Driver {
|
||
let root = std::env::temp_dir().join(format!("notescraft-sheet-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(&root).expect("root");
|
||
seed(&root, files);
|
||
bind_folder(&root, &stub.base, "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the vault");
|
||
app_for(&root, &settings_dir(&root), size)
|
||
}
|
||
|
||
/// Writes `(path, content)` into `root`, creating the folders.
|
||
fn seed(root: &Path, files: &[(&str, &str)]) {
|
||
for (path, text) in files {
|
||
if let Some((folder, _)) = path.rsplit_once('/') {
|
||
std::fs::create_dir_all(root.join(folder)).expect("folder");
|
||
}
|
||
std::fs::write(root.join(path), text).expect("seed");
|
||
}
|
||
}
|
||
|
||
/// The settings dir beside `root`, one per root so parallel tests never share a
|
||
/// `config.toml`; the vault never holds the settings (SPEC 4.1).
|
||
fn settings_dir(root: &Path) -> std::path::PathBuf {
|
||
let name = root.file_name().map_or_else(String::new, |name| name.to_string_lossy().into_owned());
|
||
root.parent().unwrap_or(root).join(format!("{name}-config"))
|
||
}
|
||
|
||
/// An app over an already seeded and bound vault at `root`.
|
||
fn app_for(root: &Path, config: &Path, size: egui::Vec2) -> Driver {
|
||
let mut session = Session::open(root).expect("session");
|
||
session.use_settings_dir(config);
|
||
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
|
||
app.state.animations = false;
|
||
app.open_note("a.md");
|
||
Driver { app, ctx: Context::default(), time: 1.0, size }
|
||
}
|
||
|
||
impl Driver {
|
||
/// One frame with `events`; the texts it painted.
|
||
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
|
||
self.time += 0.05;
|
||
let input = RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, self.size)), time: Some(self.time), events, ..Default::default() };
|
||
let app = &mut self.app;
|
||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui.ctx()));
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.iter()
|
||
.filter_map(|shape| match &shape.shape {
|
||
egui::Shape::Text(text) => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos, galley: text.galley.clone() }),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn settle(&mut self) -> Vec<Painted> {
|
||
let mut last = Vec::new();
|
||
for _ in 0..24 {
|
||
last = self.frame(Vec::new());
|
||
}
|
||
last
|
||
}
|
||
|
||
fn click(&mut self, at: Pos2) -> Vec<Painted> {
|
||
self.frame(vec![Event::PointerMoved(at)]);
|
||
self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]);
|
||
self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }]);
|
||
self.settle()
|
||
}
|
||
|
||
/// The centre of the pill's painted `Synced` label; the hit area holds it.
|
||
fn pill_centre(&mut self) -> Pos2 {
|
||
let texts = self.settle();
|
||
let status = self.app.session.sync_worker().map(|worker| format!("{:?}", worker.status()));
|
||
let pill = texts
|
||
.iter()
|
||
.find(|painted| painted.text == "Synced")
|
||
.unwrap_or_else(|| panic!("no pill among {:?} -- worker {status:?}", texts.iter().map(|painted| painted.text.as_str()).collect::<Vec<_>>()));
|
||
pill.pos + pill.galley.size() / 2.0
|
||
}
|
||
}
|
||
|
||
fn painted<'a>(texts: &'a [Painted], wanted: &str) -> Option<&'a Painted> {
|
||
texts.iter().find(|painted| painted.text == wanted)
|
||
}
|
||
|
||
fn expect<'a>(texts: &'a [Painted], wanted: &str) -> &'a Painted {
|
||
painted(texts, wanted).unwrap_or_else(|| panic!("no {wanted:?} among {:?}", texts.iter().map(|painted| painted.text.as_str()).collect::<Vec<_>>()))
|
||
}
|
||
|
||
/// The device list the stub answers: another device last seen an hour ago,
|
||
/// this device now.
|
||
fn devices_json(now_ms: u64) -> String {
|
||
format!(
|
||
r#"[{{"id":"d-2","name":"Laptop","created_ms":0,"last_seen_ms":{},"current":false}},{{"id":"d-1","name":"Pixel 9","created_ms":0,"last_seen_ms":{},"current":true}}]"#,
|
||
now_ms - 3_600_000,
|
||
now_ms
|
||
)
|
||
}
|
||
|
||
/// The Unix time now, in milliseconds.
|
||
fn now_ms() -> u64 {
|
||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(u64::MAX))
|
||
}
|
||
|
||
/// The worker runs fast: the tests never wait for the real debounce.
|
||
fn fast_worker(d: &mut Driver) {
|
||
if let Some(worker) = d.app.session.sync_worker() {
|
||
worker.set_timings(Timings {
|
||
debounce: Duration::from_millis(10),
|
||
poll: Duration::from_mins(15),
|
||
backoff: Duration::from_millis(10),
|
||
backoff_max: Duration::from_secs(1),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Waits, bounded, until the worker is idle; the cycles land before the frames read it.
|
||
fn worker_idle(d: &mut Driver) {
|
||
let deadline = Instant::now() + WAIT;
|
||
while Instant::now() < deadline && !d.app.session.sync_worker().is_some_and(|worker| worker.status().phase == notescraft_engine::SyncPhase::Idle) {
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
}
|
||
|
||
/// The centre of the painted `label` once it holds the same place for several frames in a row (async content moves it).
|
||
fn stable_centre(d: &mut Driver, label: &str) -> Pos2 {
|
||
let deadline = Instant::now() + WAIT;
|
||
let (mut last, mut same) = (None, 0_u32);
|
||
loop {
|
||
let texts = d.frame(Vec::new());
|
||
let at = painted(&texts, label).map(|found| found.pos + found.galley.size() / 2.0);
|
||
same = if at.is_some() && at == last { same + 1 } else { 0 };
|
||
last = at;
|
||
if let (Some(at), true) = (at, same >= 5) {
|
||
return at;
|
||
}
|
||
assert!(Instant::now() < deadline, "{label:?} never rested: {at:?}");
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_pill_opens_the_sheet_and_lists_the_current_device_first() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut d = driven(&stub, &[("a.md", "A")], PHONE);
|
||
d.settle();
|
||
fast_worker(&mut d);
|
||
stub.wait_for(|requests| requests.iter().any(|seen| seen.method == "PUT" && seen.path.ends_with("a.md")));
|
||
worker_idle(&mut d);
|
||
|
||
let from = d.pill_centre();
|
||
let texts = d.click(from);
|
||
assert!(d.app.sync_sheet.is_some(), "the pill opened the sheet");
|
||
let current = expect(&texts, "This phone");
|
||
let laptop = expect(&texts, "Laptop");
|
||
assert!(current.pos.y < laptop.pos.y, "the current device first: {current:?} vs {laptop:?}");
|
||
assert!(
|
||
texts.iter().any(|painted| painted.text.contains("last seen")),
|
||
"the ages show: {:?}",
|
||
texts.iter().map(|painted| painted.text.as_str()).collect::<Vec<_>>()
|
||
);
|
||
assert!(!texts.iter().any(|painted| painted.text.contains(TOKEN)), "the token never reaches the screen");
|
||
assert!(painted(&texts, "Everything is up to date").is_some(), "the status headline shows");
|
||
assert!(painted(&texts, "Sync now").is_some(), "the primary button shows");
|
||
}
|
||
|
||
#[test]
|
||
fn the_conflicts_row_appears_only_over_a_conflict_file_on_disk() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut with = driven(&stub, &[("a.md", "A"), ("a (conflict Laptop 2026-10-20).md", "A")], PHONE);
|
||
with.settle();
|
||
let from = with.pill_centre();
|
||
with.click(from);
|
||
let over = with.settle();
|
||
assert!(painted(&over, "1 conflicts to review").is_some(), "the row shows");
|
||
drop(with);
|
||
|
||
let mut none_driver = driven(&stub, &[("a.md", "A")], PHONE);
|
||
none_driver.settle();
|
||
let from = none_driver.pill_centre();
|
||
none_driver.click(from);
|
||
let none = none_driver.settle();
|
||
assert!(painted(&none, "conflicts to review").is_none(), "nothing to review: {:?}", none.iter().map(|painted| painted.text.as_str()).collect::<Vec<_>>());
|
||
let _ = none_driver.app.sync_sheet.take();
|
||
}
|
||
|
||
#[test]
|
||
fn toggling_attachments_persists_and_the_first_cycle_skips_the_png() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut d = driven(&stub, &[("a.md", "A"), ("img/scan.png", "bytes")], PHONE);
|
||
d.settle();
|
||
worker_idle(&mut d);
|
||
let from = d.pill_centre();
|
||
d.click(from);
|
||
// The sheet grows when the device list lands from the stub on a worker thread: click only once the label rests.
|
||
let hit = stable_centre(&mut d, "Sync attachments");
|
||
d.click(hit);
|
||
// The stored option goes into the settings file the next load reads; the
|
||
// vault root and its settings dir outlive the app they were opened over.
|
||
let root = d.app.session.vault().root().to_path_buf();
|
||
let config = settings_dir(&root);
|
||
let read = |text: &str| text.lines().filter(|line| line.contains("attachments") || line.contains("[sync]")).collect::<Vec<_>>().join(", ");
|
||
let stored = std::fs::read_to_string(config.join("config.toml")).unwrap_or_default();
|
||
assert!(stored.contains("attachments = false"), "the toggle stored: {:?}", read(&stored));
|
||
drop(d);
|
||
|
||
let mut again = app_for(&root, &config, PHONE);
|
||
again.settle();
|
||
fast_worker(&mut again);
|
||
worker_idle(&mut again);
|
||
assert!(!stub.uploaded("scan.png"), "the png never left: {}", stub_uploaded_list(&stub));
|
||
assert!(again.app.session.sync_worker().is_some_and(|worker| worker.status().phase == notescraft_engine::SyncPhase::Idle), "the cycle ran");
|
||
drop(again);
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
let _ = std::fs::remove_dir_all(&config);
|
||
}
|
||
|
||
/// The paths the stub saw written to, for the failure messages.
|
||
fn stub_uploaded_list(stub: &Stub) -> String {
|
||
stub.requests().iter().map(|seen| format!("{}/{}", seen.method, seen.path)).collect::<Vec<_>>().join(", ")
|
||
}
|
||
|
||
#[test]
|
||
fn sync_now_wakes_the_worker() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut d = driven(&stub, &[("a.md", "A")], PHONE);
|
||
d.settle();
|
||
fast_worker(&mut d);
|
||
stub.wait_for(|requests| requests.iter().any(|seen| seen.method == "PUT" && seen.path.ends_with("a.md")));
|
||
worker_idle(&mut d);
|
||
let before = stub.change_polls();
|
||
let from = d.pill_centre();
|
||
d.click(from);
|
||
let texts = d.settle();
|
||
let label = expect(&texts, "Sync now");
|
||
let hit = label.pos + label.galley.size() / 2.0;
|
||
d.click(hit);
|
||
stub.wait_for(|requests| requests.iter().filter(|seen| seen.method == "GET" && seen.path.ends_with("/changes")).count() > before as usize);
|
||
assert!(stub.change_polls() > before, "the change log was polled again");
|
||
assert!(d.app.sync_sheet.is_some(), "Sync now keeps the sheet open");
|
||
}
|
||
|
||
#[test]
|
||
fn the_sheet_closes_with_x_and_escape_and_a_tap_outside() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut d = driven(&stub, &[("a.md", "A")], PHONE);
|
||
d.settle();
|
||
let from = d.pill_centre();
|
||
d.click(from);
|
||
assert!(d.app.sync_sheet.is_some(), "open");
|
||
d.settle();
|
||
// The × sits in the title row, in the top-right of the sheet.
|
||
let sheet = d.ctx.read_response(egui::Id::new("notescraft-sync-sheet")).expect("the sheet area");
|
||
let rect = sheet.rect;
|
||
d.click(egui::pos2(rect.right() - 16.0 - 22.0, rect.top() + 16.0 + 4.0 + 12.0 + 22.0));
|
||
assert!(d.app.sync_sheet.is_none(), "the × closed the sheet");
|
||
|
||
let again_from = d.pill_centre();
|
||
d.click(again_from);
|
||
assert!(d.app.sync_sheet.is_some(), "open again");
|
||
let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||
d.frame(vec![escape]);
|
||
d.settle();
|
||
assert!(d.app.sync_sheet.is_none(), "Escape closed the sheet");
|
||
|
||
let third_from = d.pill_centre();
|
||
d.click(third_from);
|
||
assert!(d.app.sync_sheet.is_some(), "open again for the tap outside");
|
||
d.click(egui::pos2(20.0, 20.0));
|
||
assert!(d.app.sync_sheet.is_none(), "a tap on the dimmed area closed the sheet");
|
||
}
|
||
|
||
#[test]
|
||
fn at_desktop_width_the_pill_opens_the_settings_page() {
|
||
let stub = Stub::spawn(&devices_json(now_ms()));
|
||
let mut d = driven(&stub, &[("a.md", "A")], DESKTOP);
|
||
d.settle();
|
||
let from = d.pill_centre();
|
||
let texts = d.click(from);
|
||
assert!(d.app.sync_sheet.is_none(), "no sheet at desktop width");
|
||
assert_eq!(d.app.tabs.active_path(), Some(notescraft_ui_egui::tabs::SETTINGS_PATH), "the settings page opened");
|
||
assert!(
|
||
texts.iter().any(|painted| painted.text == "Server") && texts.iter().any(|painted| painted.text == "Sync now"),
|
||
"the Sync page shows: {:?}",
|
||
texts.iter().map(|painted| painted.text.as_str()).collect::<Vec<_>>()
|
||
);
|
||
}
|