Files
Marcos Paulo b9d21de5b2
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Bases tab with the Table view, popovers and the Maps tile service
.base files open as a tab: view switcher (add, duplicate, rename, delete, reorder), toolbar, status line, a raw-YAML fallback card with the parse-error line; virtualised table (only visible rows painted, pages fetched on demand) with sticky header, sort arrows, resizable columns persisted to columnSize, grouping with counts and folding, summary row, typed cells, links that open notes; Properties popover (search, reorder, formulas), Sort and Group menus and a filter builder that round-trips the docs' expression forms (Bases tasks 14-16, Claude Sonnet). Maps tile service: https-only fetcher with the project User-Agent, 5 s timeout, 256 KiB cap, image/png check, burst 8 then 1 request per second and 2 in flight, disk cache with a 7-day floor and size cap, maps.status / maps.clear_cache / maps.tile commands, maps.* settings off by default, nothing runs while disabled (task 12, MiniMax M3.1 Flash, OpenCode Zen free route). Verified with the full cargo xtask ci on a copy of the working tree (4572 tests, clippy -D warnings, layers, tree, i18n). Gaps: no Summarize menu, no cards/list/map views yet, view edits bypass base.view.*.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:54:54 -03:00

198 lines
7.4 KiB
Rust

//! A fake tile server for the Maps tests: it answers tile requests, counts
//! them, remembers the `User-Agent` and watches how many run at once.
//!
//! It speaks plain HTTP on the loopback interface, like the stub of the
//! publish tests; the fetcher of the app only speaks `https://`, so the tests
//! that need the stub use the test-only constructor.
#![allow(dead_code)] // shared test helpers: each test file uses only some of them
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// The bytes the stub answers with when it is asked for a tile.
pub const TILE: &[u8] = b"\x89PNG\r\n\x1a\nnotescraft-test-tile";
/// What the stub answers with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
/// A PNG tile.
Png,
/// A body that is not a PNG.
Text,
/// A body larger than the cap of the fetcher.
Huge,
}
/// One request the stub received.
#[derive(Debug, Clone)]
pub struct Request {
/// The path of the request line.
pub path: String,
/// The `User-Agent` header, when there was one.
pub user_agent: Option<String>,
/// The `Referer` header, when there was one.
pub referer: Option<String>,
}
/// The fake tile server.
pub struct Stub {
/// The base URL of the stub.
pub base: String,
/// The kind of answer the stub gives.
answer: Arc<Mutex<Answer>>,
/// The requests the stub received.
seen: Arc<Mutex<Vec<Request>>>,
/// The requests running right now.
running: Arc<AtomicUsize>,
/// The most requests that ran at once.
peak: Arc<AtomicUsize>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Stub {
/// Starts a stub on an ephemeral port.
pub fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
let answer = Arc::new(Mutex::new(Answer::Png));
let seen = Arc::new(Mutex::new(Vec::new()));
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let thread_answer = Arc::clone(&answer);
let thread_seen = Arc::clone(&seen);
let thread_running = Arc::clone(&running);
let thread_peak = Arc::clone(&peak);
let thread_stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(false);
let Ok(mut writer) = stream.try_clone() else { continue };
let mut reader = BufReader::new(stream);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(30)));
let answer = Arc::clone(&thread_answer);
let seen = Arc::clone(&thread_seen);
let running = Arc::clone(&thread_running);
let peak = Arc::clone(&thread_peak);
std::thread::spawn(move || {
let now = running.fetch_add(1, Ordering::SeqCst).saturating_add(1);
peak.fetch_max(now, Ordering::SeqCst);
if let Some(request) = read_request(&mut reader) {
seen.lock().expect("seen").push(request.clone());
let (kind, body) = body_of(&answer);
// A slow answer, so the tests can watch how
// many requests run at the same time.
std::thread::sleep(Duration::from_millis(30));
// The slot is given back before the answer
// leaves, so a client that already read the
// body never meets a third request.
running.fetch_sub(1, Ordering::SeqCst);
write_answer(&mut writer, &request, kind, &body);
} else {
running.fetch_sub(1, Ordering::SeqCst);
}
});
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), answer, seen, running, peak, stop }
}
/// The kind of answer the stub gives from now on.
pub fn answer_with(&self, answer: Answer) {
*self.answer.lock().expect("answer") = answer;
}
/// Every request the stub received.
pub fn requests(&self) -> Vec<Request> {
self.seen.lock().expect("seen").clone()
}
/// How many requests the stub received.
pub fn count(&self) -> usize {
self.seen.lock().expect("seen").len()
}
/// The most requests that ran at the same time.
pub fn peak(&self) -> usize {
self.peak.load(Ordering::SeqCst)
}
/// The requests running right now.
pub fn running(&self) -> usize {
self.running.load(Ordering::SeqCst)
}
/// Stops the stub: the port closes and no request is answered any more.
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl Drop for Stub {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// The content type and the body of the answer.
fn body_of(answer: &Mutex<Answer>) -> (&'static str, Vec<u8>) {
match *answer.lock().expect("answer") {
Answer::Png => ("image/png", TILE.to_vec()),
Answer::Text => ("text/plain", b"not a tile".to_vec()),
Answer::Huge => ("image/png", vec![b'x'; 300 * 1024]),
}
}
/// Reads one request of a connection.
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let _method = parts.next()?;
let path = parts.next()?.to_owned();
let mut user_agent = None;
let mut referer = None;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let trimmed = header.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((name, value)) = trimmed.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim().to_owned();
if name == "user-agent" {
user_agent = Some(value);
} else if name == "referer" {
referer = Some(value);
}
}
}
Some(Request { path, user_agent, referer })
}
/// Writes one answer of the stub.
fn write_answer(stream: &mut TcpStream, _request: &Request, kind: &str, body: &[u8]) {
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}