//! The "Waiting to sync" queue driven the way a user does: real frames at //! 390 x 844 over a vault bound to a stub server (designs `03-sync-sheet`, //! `07-offline`). use egui::{Context, Event, Key, PointerButton, Pos2, RawInput}; use notescraft_engine::{Session, SyncPhase, Timings, bind_folder}; use notescraft_ui_egui::app::NotesApp; use notescraft_ui_egui::panels::sync_queue::Queue; use notescraft_ui_egui::shell::compact::CompactPage; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; static NEXT: AtomicU64 = AtomicU64::new(0); /// The window of a phone. const PHONE: egui::Vec2 = egui::vec2(390.0, 844.0); /// The token of the bound vault; it must never reach the screen. const TOKEN: &str = "secret-token"; /// Longest the tests wait for the worker. const WAIT: Duration = Duration::from_secs(5); /// A stub server: the device list, an empty change log and one applied upload. struct Stub { base: String, stop: Arc, polls: Arc, } impl Stub { fn spawn() -> 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":1,"mtime_ms":0,"seq":1,"deleted":false}"#; let empty = r#"{"head":0,"records":[],"more":false}"#; let answer = |body: &str| format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()); let stop = Arc::new(AtomicBool::new(false)); let polls = Arc::new(AtomicU64::new(0)); let (thread_stop, thread_polls) = (Arc::clone(&stop), Arc::clone(&polls)); // The listener is nonblocking so the stop flag works: the accept loop polls // it, and after an accept the stream goes back to blocking reads. listener.set_nonblocking(true).expect("nonblocking listener"); std::thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { let accepted = match listener.accept() { Ok((stream, _)) => Some(stream), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => None, Err(_) => break, }; let Some(stream) = accepted else { std::thread::sleep(Duration::from_millis(2)); continue; }; stream.set_nonblocking(false).expect("the stream blocks its reads again"); let (mut reader, mut writer) = (BufReader::new(stream.try_clone().expect("clone")), stream); while let Some((method, path, length)) = read_head(&mut reader) { if length > 0 { let mut body = vec![0_u8; length]; let _ = reader.read_exact(&mut body); } if method == "PUT" { let created = format!("HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{record}", record.len()); let _ = writer.write_all(created.as_bytes()); } else if path.contains("/changes") { thread_polls.fetch_add(1, Ordering::Relaxed); let _ = writer.write_all(answer(empty).as_bytes()); } else { let _ = writer.write_all("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".as_bytes()); } let _ = writer.flush(); } } }); Self { base: format!("http://127.0.0.1:{port}"), stop, polls } } /// How often the change log was polled. fn poll_count(&self) -> u64 { self.polls.load(Ordering::Relaxed) } } impl Drop for Stub { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); } } /// The request line and the content length of one request. fn read_head(reader: &mut BufReader) -> Option<(String, String, usize)> { 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); } } Some((method, path, length)) } struct Driver { app: NotesApp, ctx: Context, time: f64, } /// An app over a fresh vault bound to `stub` at phone size, animations off. fn driven(stub_base: &str, files: &[(&str, &str)]) -> Driver { let root = std::env::temp_dir().join(format!("notescraft-queue-{}-{}", 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"); let config = root.parent().unwrap_or(&root).join("notescraft-queue-config"); 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; Driver { app, ctx: Context::default(), time: 1.0 } } /// 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"); } } impl Driver { fn frame(&mut self, events: Vec) -> Vec { self.time += 0.05; let input = RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, PHONE)), 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 { let mut last = Vec::new(); for _ in 0..24 { last = self.frame(Vec::new()); } last } /// The centre of the first painted `wanted` label, for tapping it. fn center(&mut self, wanted: &str) -> Pos2 { let painted = self.find(wanted); painted.pos + painted.galley.size() / 2.0 } /// The first painted text that is exactly `wanted`, or panics. fn find(&mut self, wanted: &str) -> Painted { let texts = self.settle(); let all: Vec = texts.iter().map(|painted| painted.text.clone()).collect(); texts.into_iter().find(|painted| painted.text == wanted).unwrap_or_else(move || panic!("no {wanted:?} among {all:?}")) } /// A click through press and release on `at`. fn click(&mut self, at: Pos2) { 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 escape key. fn escape(&mut self) { self.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]); self.settle(); } /// The worker runs fast enough for the tests. fn fast_worker(&mut self) { if let Some(worker) = self.app.session.sync_worker() { worker.set_timings(Timings { debounce: Duration::from_millis(10), poll: Duration::from_mins(15), backoff: Duration::from_millis(20), backoff_max: Duration::from_millis(60), }); } } /// Waits, bounded, until the worker's phase and pending count match. fn worker_reaches(&mut self, phase: SyncPhase, pending: usize) { let deadline = Instant::now() + WAIT; while Instant::now() < deadline { if self.app.session.sync_worker().is_some_and(|worker| worker.status().phase == phase && worker.status().pending == pending) { return; } std::thread::sleep(Duration::from_millis(10)); } panic!("the worker never reached {phase:?} with {pending} pending: {:?}", self.app.session.sync_worker().map(notescraft_engine::SyncWorker::status)); } } /// A painted text: what it says, where it starts and its galley. struct Painted { text: String, pos: Pos2, galley: Arc, } /// Waits until the worker phase is the wanted one, no count asked. fn phase_of(d: &mut Driver) -> Option { d.app.session.sync_worker().map(|worker| worker.status().phase) } /// Waits, bounded, for an idle worker that has finished a full cycle: a fresh /// worker's status is Idle at once, so the answer's report is the proof. fn wait_idle(d: &mut Driver) { let deadline = Instant::now() + WAIT; while Instant::now() < deadline { if let Some(worker) = d.app.session.sync_worker() { let status = worker.status(); if status.last_report.is_some() && status.phase == SyncPhase::Idle { break; } } std::thread::sleep(Duration::from_millis(10)); } } /// Opens the queue over a vault whose worker is idle, without the sheet. fn idle_queue(d: &mut Driver) { wait_idle(d); d.app.state.compact_page = CompactPage::Notes; d.app.sync_queue = Some(Queue::open(&d.app.session)); d.settle(); } /// The pill shows "Offline" and the sheet has the waiting row; the queue it /// opens lists the file, its folder and the kinds. #[test] fn the_waiting_row_opens_the_queue_with_the_kinds() { let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A"), ("img/f.png", "bytes")]); drop(stub); // the phone is offline; the local changes wait d.settle(); d.fast_worker(); d.worker_reaches(SyncPhase::Offline, 2); // The status bar (and its pill) shows when a note is open. d.app.open_note("a.md"); d.settle(); let offline_pill = d.center("Offline"); d.click(offline_pill); assert!(d.app.sync_sheet.is_some(), "the pill opened the sheet"); let _ = d.find("2 changes waiting"); let row = d.center("2 changes waiting"); d.click(row); assert!(d.app.sync_sheet.is_none(), "the row closed the sheet"); assert!(d.app.sync_queue.is_some(), "the row opened the queue"); let texts = d.settle(); for wanted in ["Waiting to sync", "2 changes waiting", "a.md", "1 B", "Sync now"] { assert!( texts.iter().any(|painted| painted.text == wanted), "no {wanted:?} among {:?}", texts.iter().map(|painted| painted.text.as_str()).collect::>() ); } assert!(texts.iter().any(|painted| painted.text == "img"), "the folder of img/f.png shows muted"); assert!( texts.iter().filter(|painted| painted.text == "New").count() == 2, "two New chips: {:?}", texts.iter().map(|painted| painted.text.as_str()).collect::>() ); d.escape(); assert!(d.app.sync_queue.is_none(), "Escape closed the queue"); } /// A recorded base and a deleted file make the red Deleted chip. #[test] fn the_queue_lists_a_deleted_file() { let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A"), ("img/f.png", "bytes")]); drop(stub); d.settle(); d.fast_worker(); d.worker_reaches(SyncPhase::Offline, 2); // the worker is offline, so nothing touches the bases again. let same = "a".repeat(64); let bases = format!(r#"{{"last_seq":2,"bases":{{"deleted.md":{{"hash":"{same}","seq":2}}}}}}"#); d.app.session.vault().write_private(".notescraft/sync/state.json", bases.as_bytes()).expect("record the bases"); d.app.state.compact_page = CompactPage::Notes; d.app.sync_queue = Some(Queue::open(&d.app.session)); let texts = d.settle(); let _ = d.find("Deleted"); assert!( texts.iter().any(|painted| painted.text == "New"), "the unread files are New rows: {:?}", texts.iter().map(|painted| painted.text.as_str()).collect::>() ); let _ = d.find("deleted.md"); } /// Everything synced: the queue says so, with no rows and no chips. #[test] fn the_empty_state_shows_when_the_vault_is_synced() { let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A")]); d.settle(); d.fast_worker(); idle_queue(&mut d); let texts = d.settle(); let _ = d.find("Everything is up to date."); assert!(!texts.iter().any(|painted| painted.text == "New"), "no chips in the empty state"); assert!(d.app.sync_queue.is_some(), "the queue stays open"); d.app.sync_queue = None; } /// Sync now in the queue reaches the worker: the server sees a second put. #[test] fn sync_now_in_the_queue_wakes_the_worker() { let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A")]); d.settle(); d.fast_worker(); idle_queue(&mut d); let before = stub.poll_count(); let button = d.center("Sync now"); d.click(button); let deadline = Instant::now() + WAIT; while Instant::now() < deadline && stub.poll_count() == before { std::thread::sleep(Duration::from_millis(10)); } assert!(stub.poll_count() > before, "Sync now woke the worker: {} -> {}", before, stub.poll_count()); assert!(d.app.sync_queue.is_some(), "Sync now keeps the queue open"); d.app.sync_queue = None; } /// The offline banner sits on the compact Notes page while the phase is /// Offline, and never over a note page. #[test] fn the_offline_banner_shows_only_while_offline() { let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A")]); drop(stub); d.settle(); d.fast_worker(); d.worker_reaches(SyncPhase::Offline, 1); d.app.state.compact_page = CompactPage::Notes; let texts = d.settle(); let _ = d.find("You are offline"); assert!(texts.iter().any(|painted| painted.text.contains("Edits are saved on this phone")), "the line under the headline"); // On the Note page (a note open) the banner has no room and no job. d.app.state.compact_page = CompactPage::Note; let note_page = d.settle(); assert!(!note_page.iter().any(|painted| painted.text == "You are offline"), "no banner over a note"); // A healthy vault: no banner on the Notes page either (the phase never left Idle). let stub = Stub::spawn(); let mut d = driven(&stub.base, &[("a.md", "A")]); d.settle(); d.fast_worker(); wait_idle(&mut d); d.app.state.compact_page = CompactPage::Notes; let after = d.settle(); assert_eq!(phase_of(&mut d), Some(SyncPhase::Idle), "the worker is idle"); assert!(!after.iter().any(|painted| painted.text == "You are offline"), "the banner never shows while idle"); }