b9d21de5b2
.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>
245 lines
10 KiB
Rust
245 lines
10 KiB
Rust
//! The tile fetcher, the gate and the cache of the Maps feature (D13).
|
|
//!
|
|
//! No test here talks to a real server: the stub of `maps_tiles_stub` answers
|
|
//! on the loopback interface and the clock is the test's own, so the rate
|
|
//! limit and the seven day floor are exercised without waiting.
|
|
|
|
mod common;
|
|
mod maps_tiles_stub;
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use common::{temp_config, temp_vault};
|
|
use maps_tiles_stub::{Answer, Stub, TILE};
|
|
use notescraft_engine::tiles::{BURST, Clock, HttpTileFetcher, SystemClock, TileError, TileService, validate_template};
|
|
use notescraft_engine::{Error, MapsConfig, Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
/// A clock the test moves by hand.
|
|
struct FakeClock(Arc<AtomicU64>);
|
|
|
|
impl Clock for FakeClock {
|
|
fn now_ms(&self) -> u64 {
|
|
self.0.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
/// The service of a test: the stub behind the template, the clock of the test
|
|
/// and the cache under `root`.
|
|
fn service(stub: &Stub, clock: &Arc<AtomicU64>, root: &Path, max_bytes: u64) -> TileService {
|
|
let template = format!("{}/{{z}}/{{x}}/{{y}}.png", stub.base);
|
|
TileService::new(true, &template, root, max_bytes, Box::new(HttpTileFetcher::for_tests()), Box::new(FakeClock(Arc::clone(clock))))
|
|
}
|
|
|
|
/// A clock that starts at the real time, so the freshest tile of the cache is
|
|
/// young when the test looks at it.
|
|
fn clock() -> Arc<AtomicU64> {
|
|
Arc::new(AtomicU64::new(SystemClock.now_ms()))
|
|
}
|
|
|
|
#[test]
|
|
fn the_stub_sees_the_user_agent_and_no_referer() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_agent");
|
|
let clock = clock();
|
|
let service = service(&stub, &clock, &vault.path().join("cache"), 1024 * 1024);
|
|
let bytes = service.tile(10, 518, 352).expect("the tile");
|
|
assert_eq!(bytes, TILE, "the bytes of the stub come back");
|
|
let requests = stub.requests();
|
|
assert_eq!(requests.len(), 1, "{requests:?}");
|
|
let request = &requests[0];
|
|
assert!(request.path.ends_with("/10/518/352.png"), "{}", request.path);
|
|
let agent = request.user_agent.as_deref().unwrap_or_default();
|
|
assert!(agent.starts_with("NotesCraft/"), "the agent names the app: {agent}");
|
|
assert!(agent.contains("masi/notescraft"), "the agent names the project: {agent}");
|
|
assert!(request.referer.is_none(), "no referer is sent: {request:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn twenty_tiles_keep_the_rate_limit_and_the_concurrency_cap() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_rate");
|
|
let clock = clock();
|
|
let service = Arc::new(service(&stub, &clock, &vault.path().join("cache"), 1024 * 1024));
|
|
let mut handles = Vec::new();
|
|
for index in 0..20_u32 {
|
|
let service = Arc::clone(&service);
|
|
handles.push(std::thread::spawn(move || service.tile(index, 0, 0)));
|
|
}
|
|
let results: Vec<_> = handles.into_iter().map(|handle| handle.join().expect("the thread")).collect();
|
|
let served = results.iter().filter(|result| result.is_ok()).count();
|
|
assert!(served <= BURST as usize, "the burst of {BURST} is the cap, {served} tiles were served");
|
|
assert!(served > 0, "the burst is served");
|
|
assert_eq!(stub.count(), served, "every served tile was a request");
|
|
assert!(stub.peak() <= 2, "at most two requests run at once, {} did", stub.peak());
|
|
assert!(results.iter().any(Result::is_err), "the rate limit refused the rest");
|
|
assert!(
|
|
results.iter().filter(|result| result.is_err()).all(|result| matches!(result, Err(TileError::Busy))),
|
|
"the refusals are the rate limit: {:?}",
|
|
results.iter().filter(|result| result.is_err()).collect::<Vec<_>>()
|
|
);
|
|
// A second later the bucket holds one request again.
|
|
clock.fetch_add(1_000, Ordering::Relaxed);
|
|
assert!(service.tile(100, 0, 0).is_ok(), "a second refills one request");
|
|
}
|
|
|
|
#[test]
|
|
fn an_oversized_body_and_a_wrong_content_type_are_dropped() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_hostile");
|
|
let clock = clock();
|
|
let service = service(&stub, &clock, &vault.path().join("cache"), 1024 * 1024);
|
|
stub.answer_with(Answer::Huge);
|
|
match service.tile(1, 0, 0) {
|
|
Err(TileError::TooLarge { .. }) => {}
|
|
other => panic!("an oversized body is dropped, got {other:?}"),
|
|
}
|
|
stub.answer_with(Answer::Text);
|
|
match service.tile(2, 0, 0) {
|
|
Err(TileError::BadContentType(_)) => {}
|
|
other => panic!("a body that is not a PNG is dropped, got {other:?}"),
|
|
}
|
|
let status = service.status();
|
|
assert_eq!(status.tiles, 0, "nothing hostile reaches the cache");
|
|
assert_eq!(status.cache_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cached_tile_is_served_with_the_stub_stopped() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_offline");
|
|
let clock = clock();
|
|
let service = service(&stub, &clock, &vault.path().join("cache"), 1024 * 1024);
|
|
let bytes = service.tile(3, 0, 0).expect("the tile");
|
|
assert_eq!(stub.count(), 1);
|
|
stub.stop();
|
|
std::thread::sleep(Duration::from_millis(30));
|
|
let again = service.tile(3, 0, 0).expect("the cached tile is served offline");
|
|
assert_eq!(again, bytes);
|
|
assert_eq!(stub.count(), 1, "the stub was asked once");
|
|
}
|
|
|
|
#[test]
|
|
fn a_tile_of_seven_days_is_fetched_again() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_expiry");
|
|
let clock = clock();
|
|
let service = service(&stub, &clock, &vault.path().join("cache"), 1024 * 1024);
|
|
let bytes = service.tile(4, 0, 0).expect("the tile");
|
|
assert_eq!(stub.count(), 1);
|
|
clock.fetch_add(7 * 24 * 60 * 60 * 1_000 - 60_000, Ordering::Relaxed);
|
|
assert_eq!(service.tile(4, 0, 0).expect("the fresh tile"), bytes);
|
|
assert_eq!(stub.count(), 1, "a tile of seven days is still fresh");
|
|
clock.fetch_add(120_000, Ordering::Relaxed);
|
|
assert_eq!(service.tile(4, 0, 0).expect("the refetched tile"), bytes);
|
|
assert_eq!(stub.count(), 2, "a tile of seven days and a moment is fetched again");
|
|
}
|
|
|
|
#[test]
|
|
fn the_cache_drops_the_oldest_tiles_at_its_cap() {
|
|
let stub = Stub::spawn();
|
|
let vault = temp_vault("maps_evict");
|
|
let clock = clock();
|
|
let service = service(&stub, &clock, &vault.path().join("cache"), 60);
|
|
for index in 0..3_u32 {
|
|
service.tile(1, 0, index).expect("the tile");
|
|
}
|
|
let status = service.status();
|
|
assert_eq!(status.tiles, 2, "the oldest tile went");
|
|
assert_eq!(status.cache_bytes, 56, "two tiles of 28 bytes are left");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bad_template_is_refused() {
|
|
for (template, reason) in [("http://host.tld/{z}/{x}/{y}.png", "https://"), ("https://host.tld/{x}/{y}.png", "{z}"), ("https:///{z}/{x}/{y}.png", "host")] {
|
|
let error = validate_template(template).expect_err("the template is refused");
|
|
assert!(error.contains(reason), "`{template}` was refused with `{error}`");
|
|
}
|
|
let maps = MapsConfig { tile_url: "http://host.tld/{z}/{x}/{y}.png".to_owned(), ..MapsConfig::default() };
|
|
assert!(maps.validate().is_err(), "the settings refuse a plain http template");
|
|
assert!(!maps.validate().unwrap_err().to_string().contains("tile.openstreetmap"), "the reason names the field");
|
|
}
|
|
|
|
/// A session on a throw-away vault, with its own settings folder.
|
|
struct Fixture {
|
|
_vault: common::TempVault,
|
|
_config: common::TempVault,
|
|
session: Session,
|
|
reg: Registry<Session>,
|
|
}
|
|
|
|
impl Fixture {
|
|
fn run(&mut self, id: &str, params: &Value) -> Value {
|
|
self.reg.run(id, &mut self.session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
|
|
}
|
|
|
|
fn fail(&mut self, id: &str, params: &Value) -> String {
|
|
match self.reg.run(id, &mut self.session, params).expect_err("must fail") {
|
|
Error::Command { source, .. } => source.code,
|
|
other => format!("{other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A session on a throw-away vault, with its own settings folder.
|
|
fn fixture(name: &str) -> Fixture {
|
|
let vault = temp_vault(name);
|
|
let config = temp_config(name);
|
|
let mut session = Session::open(vault.path()).expect("open");
|
|
session.use_settings_dir(config.path().to_path_buf());
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register");
|
|
Fixture { _vault: vault, _config: config, session, reg }
|
|
}
|
|
|
|
#[test]
|
|
fn the_status_command_reports_that_maps_is_off() {
|
|
let mut f = fixture("maps_status");
|
|
let status = f.run("maps.status", &Value::Null);
|
|
assert_eq!(status["enabled"], false, "Maps is off until the user turns it on");
|
|
assert_eq!(status["use_tiles"], true);
|
|
assert_eq!(status["host"], "tile.openstreetmap.org");
|
|
assert_eq!(status["cache_bytes"], 0);
|
|
assert_eq!(status["tiles"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_disabled_maps_feature_asks_no_server_and_writes_no_file() {
|
|
let stub = Stub::spawn();
|
|
let mut f = fixture("maps_disabled");
|
|
let code = f.fail("maps.tile", &json!({ "z": 10, "x": 518, "y": 352 }));
|
|
assert_eq!(code, "maps_disabled");
|
|
assert_eq!(stub.count(), 0, "the stub was asked nothing");
|
|
let cache = f.session.vault().root().join(".notescraft/cache/tiles");
|
|
assert!(!cache.exists(), "a disabled feature writes no file");
|
|
let status = f.run("maps.status", &Value::Null);
|
|
assert_eq!(status["tiles"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_settings_turn_maps_on_and_refuse_a_plain_http_template() {
|
|
let mut f = fixture("maps_settings");
|
|
let done = f.run("settings.set", &json!({ "key": "maps.enabled", "value": true }));
|
|
assert_eq!(done["changed"], true);
|
|
let status = f.run("maps.status", &Value::Null);
|
|
assert_eq!(status["enabled"], true, "the settings drive the feature");
|
|
let done = f.run("settings.set", &json!({ "key": "maps.tile_url", "value": "http://host.tld/{z}/{x}/{y}.png" }));
|
|
let notes = done["notes"].as_array().cloned().unwrap_or_default();
|
|
assert!(notes.iter().any(|note| note.as_str().is_some_and(|note| note.contains("maps.tile_url"))), "the bad template is reported: {notes:?}");
|
|
let status = f.run("maps.status", &Value::Null);
|
|
assert_eq!(status["host"], "tile.openstreetmap.org", "the bad template was replaced by the default");
|
|
}
|
|
|
|
#[test]
|
|
fn the_clear_cache_command_empties_the_cache() {
|
|
let mut f = fixture("maps_clear");
|
|
let done = f.run("maps.clear_cache", &Value::Null);
|
|
assert_eq!(done["cleared"], true);
|
|
let status = f.run("maps.status", &Value::Null);
|
|
assert_eq!(status["cache_bytes"], 0);
|
|
assert_eq!(status["tiles"], 0);
|
|
}
|