Files
2026-10-21 21:01:23 -03:00

448 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<_>>()
);
}