use serde_json::{Value, json}; use crate::{Project, Session, sample}; #[cfg(not(target_arch = "wasm32"))] #[path = "tests/scene_losses.rs"] mod scene_losses; #[path = "tests/permission_policy.rs"] mod permission_policy; #[path = "tests/review_boundaries.rs"] mod review_boundaries; #[path = "tests/task_reentry.rs"] mod task_reentry; #[path = "tests/credential_output.rs"] mod credential_output; #[cfg(not(target_arch = "wasm32"))] #[path = "tests/remembered_database.rs"] mod remembered_database; #[cfg(not(target_arch = "wasm32"))] #[path = "tests/remembered_database_packets.rs"] mod remembered_database_packets; #[path = "tests/service_logins.rs"] mod service_logins; /// Keeps tests away from the user's installed plugins. fn isolate_plugins() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let dir = std::env::temp_dir().join(format!("rustgis-test-plugins-{}", std::process::id())); rustgis_plugins::store::set_dir(Some(dir)); }); } fn world() -> Session { isolate_plugins(); let mut s = Session::new(Project::default()); s.run("project.sample", &Value::Null).unwrap(); s } /// Runs a test that owns process-wide state in a fresh instance of the test executable. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn isolate_process_test(name: &str) -> bool { if std::env::var("RUSTGIS_ISOLATED_SCENARIO").as_deref() == Ok(name) { return false; } let output = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", name]) .env("RUSTGIS_ISOLATED_SCENARIO", name) .output() .unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); let mut summaries = stdout.lines().filter(|line| line.starts_with("test result:")); let ran_one = summaries.next().is_some_and(|line| line.starts_with("test result: ok. 1 passed; 0 failed; 0 ignored;")) && summaries.next().is_none(); assert!( output.status.success() && ran_one, "isolated process scenario failed or ran no test:\n{stdout}\n{}", String::from_utf8_lossy(&output.stderr) ); true } #[test] fn worldview_swaps_only_untouched_sample_countries() { use sample::{Detail, View, Worldview, swap_countries}; let countries = |s: &Session| s.map().layer_by_name("Countries").unwrap().dataset.features.len(); // Japan's view, which no other test uses, given countries that differ from the built-in ones // (every country but the first) as if downloaded. let japan = View::new(Worldview::from_code("jpn").unwrap(), Detail::Original); let mut fewer: Value = serde_json::from_str(sample::COUNTRIES).unwrap(); fewer["features"].as_array_mut().unwrap().remove(0); let mut s = world(); s.mark_saved(); let n = countries(&s); // Not before the view's countries are at hand. assert!(!sample::ready(japan)); assert!(!swap_countries(&mut s, View::DEFAULT, japan)); sample::accept(japan, fewer.to_string().into_bytes()).unwrap(); assert!(sample::ready(japan)); // The layer holds the default countries, not Japan's. assert!(!swap_countries(&mut s, japan, View::DEFAULT)); assert!(swap_countries(&mut s, View::DEFAULT, japan)); assert_eq!(countries(&s), n - 1); assert!(!s.dirty()); assert!(swap_countries(&mut s, japan, View::DEFAULT)); assert_eq!(countries(&s), n); // Not in a project with changes, or a saved one; it still holds the sample's countries (the // app then says they're kept). s.mark_unsaved(); assert!(!swap_countries(&mut s, View::DEFAULT, japan)); assert!(sample::holds_countries(&s, View::DEFAULT)); assert!(!sample::holds_countries(&s, japan)); s.mark_saved(); s.path = Some("atlas.rgis".into()); assert!(!swap_countries(&mut s, View::DEFAULT, japan)); // Not someone else's layer named Countries. let mut t = world(); t.mark_saved(); t.run("edit.deleteField", &json!({"layer": "Countries", "field": "pop_est"})).unwrap(); t.mark_saved(); // With nothing to undo, so only the layer's own countries stop the swap. t.clear_history(); assert!(!t.can_undo()); assert!(!swap_countries(&mut t, View::DEFAULT, japan)); assert!(!sample::holds_countries(&t, View::DEFAULT)); } #[test] fn a_worldview_download_that_isnt_countries_is_refused() { use sample::{Detail, View, Worldview}; let korea = View::new(Worldview::from_code("kor").unwrap(), Detail::Simplified); for bad in [b"\xff\xfe".to_vec(), b"404".to_vec(), sample::CITIES.as_bytes().to_vec()] { assert!(sample::accept(korea, bad).is_err()); } assert!(!sample::ready(korea)); // Nor is a download over the size allowed at its detail. assert!(sample::accept(korea, vec![b' '; Detail::Simplified.max_bytes() + 1]).unwrap_err().contains("more than")); // Applying it before then keeps the current view and says which one was asked for. assert_eq!(sample::apply_view("kor", "simplified"), korea); assert_ne!(sample::view(), korea); assert_eq!(sample::wanted_view("kor", "nonsense"), korea); // The default is built in. assert!(sample::ready(View::DEFAULT)); assert!(sample::download_blocking(View::DEFAULT).is_ok()); } #[test] fn an_original_worldview_downloads_gzipped() { use sample::{Detail, View, Worldview}; let gzip = |text: &[u8]| { let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 0xff]; out.extend(miniz_oxide::deflate::compress_to_vec(text, 6)); out.extend([0; 8]); out }; // Vietnam's view, original detail, which no other test uses. let v = View::new(Worldview::from_code("vnm").unwrap(), Detail::Original); // Something that unpacks to more than the original detail allows is refused, not cut. let huge = gzip(&vec![b' '; Detail::Original.max_bytes() + 1]); assert!(sample::accept(v, huge).unwrap_err().contains("can't be unpacked")); assert!(sample::accept(v, vec![0x1f, 0x8b, 8]).is_err()); assert!(sample::text(v).is_none()); sample::accept(v, gzip(sample::COUNTRIES.as_bytes())).unwrap(); assert_eq!(sample::text(v), Some(sample::COUNTRIES)); // The default worldview's original detail is a download too. assert!(!sample::ready(View::new(Worldview::DEFAULT, Detail::Original))); } #[test] fn a_failed_worldview_download_doesnt_name_the_worldview_twice() { use sample::{Detail, View, Worldview}; // Never the computer's own cache folder. sample::keep_downloads(false); // Greece's view, original detail, which no other test uses. let v = View::new(Worldview::from_code("grc").unwrap(), Detail::Original); let (tx, rx) = std::sync::mpsc::channel(); sample::fetch_with(v, Box::new(move |r| drop(tx.send(r))), |_, _| Err("http status: 404".to_string())); let e = rx.recv_timeout(std::time::Duration::from_secs(30)).unwrap().unwrap_err(); // The app's notice names the worldview; the error is only why. assert_eq!(e, "http status: 404"); assert!(sample::text(v).is_none()); } #[test] fn sample_project_loads() { let s = world(); let names: Vec<&str> = s.map().layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, ["Cities", "Rivers", "Lakes", "Countries"]); assert!(s.active_layer.is_some()); assert!(!sample::world_project().map().bookmarks.is_empty()); } #[test] fn layer_move_reorders_in_one_undo_step() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); let depth = s.undo_depth(); // Down three places: one command, one undo step. s.run("layer.move", &json!({"layer": "Cities", "to": 3})).unwrap(); assert_eq!(names(&s), ["Rivers", "Lakes", "Countries", "Cities"]); assert_eq!(s.undo_depth(), depth + 1); // Up, to the middle. s.run("layer.move", &json!({"layer": "Cities", "to": 1})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Lakes", "Countries"]); // Past the end is the bottom. s.run("layer.move", &json!({"layer": "Rivers", "to": 1e9})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Countries", "Rivers"]); // Staying put, a missing or junk position and an unknown layer fail and leave no undo step. let depth = s.undo_depth(); for p in [ json!({"layer": "Rivers", "to": 3}), json!({"layer": "Rivers", "to": 99}), json!({"layer": "Rivers"}), json!({"layer": "Rivers", "to": -1}), json!({"layer": "Rivers", "to": 1.5}), json!({"layer": "Nope", "to": 0}), ] { assert!(s.run("layer.move", &p).is_err(), "{p}"); } assert_eq!(names(&s), ["Cities", "Lakes", "Countries", "Rivers"]); assert_eq!(s.undo_depth(), depth); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Lakes", "Countries"]); } #[test] fn table_remove_takes_a_standalone_table_out_of_the_project() { let mut s = world(); let names = |s: &Session| s.project.tables.iter().map(|t| t.name.clone()).collect::>(); for name in ["sites", "notes"] { s.run("layer.addDataText", &json!({"fileName": format!("{name}.csv"), "text": "id,street\n1,Champ de Mars\n"})).unwrap(); } assert_eq!(names(&s), ["sites", "notes"]); let (depth, layers) = (s.undo_depth(), s.map().layers.len()); s.run("table.remove", &json!({"table": "sites"})).unwrap(); assert_eq!(names(&s), ["notes"]); assert_eq!((s.undo_depth(), s.map().layers.len()), (depth + 1, layers), "one undo step, and the map's layers stay"); // A table that isn't there, a layer's name and a missing or junk name fail and leave no undo step. for p in [json!({"table": "sites"}), json!({"table": "Cities"}), json!({}), json!({"table": 3}), Value::Null] { assert!(s.run("table.remove", &p).is_err(), "{p}"); } assert_eq!((names(&s), s.undo_depth()), (vec!["notes".to_string()], depth + 1)); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(names(&s), ["sites", "notes"]); // Two tables of one name have ids of their own: the id says which, the name is the first. s.run("layer.addDataText", &json!({"fileName": "notes.csv", "text": "id,city\n1,Paris\n"})).unwrap(); assert_eq!(names(&s), ["sites", "notes", "notes"]); let ids: Vec = s.project.tables.iter().map(|t| t.id).collect(); assert!(ids.iter().all(|&id| id != 0) && ids[0] != ids[1] && ids[1] != ids[2] && ids[0] != ids[2], "{ids:?}"); for table in [json!(0), json!(9999), json!(-1), json!(1.5), json!("9999")] { assert!(s.run("table.remove", &json!({"table": table})).is_err(), "{table}"); } // The position the command took before ids is refused, not ignored. assert!(s.run("table.remove", &json!({"table": "notes", "at": 2})).is_err()); assert_eq!(names(&s), ["sites", "notes", "notes"]); s.run("table.remove", &json!({"table": ids[2]})).unwrap(); assert_eq!(s.project.tables.iter().map(|t| t.id).collect::>(), [ids[0], ids[1]]); assert!(s.project.table(ids[1]).is_some_and(|t| t.field_index("street").is_some()), "the first of the two stays"); // An id in text works as well, and an id isn't given again: undo brings the table back under its own. s.run("table.remove", &json!({"table": ids[1].to_string()})).unwrap(); let again = s.run("layer.addDataText", &json!({"fileName": "more.csv", "text": "id,city\n1,Paris\n"})).unwrap(); assert!(again["id"].as_u64().is_some_and(|id| ids.iter().all(|&old| u64::from(old) != id)), "{again}"); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.tables.iter().map(|t| t.id).collect::>(), [ids[0], ids[1]]); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(s.project.tables.iter().map(|t| t.id).collect::>(), [ids[0]]); // A table added and undone leaves its id unused: the next table gets another. let undone = s.run("layer.addDataText", &json!({"fileName": "a.csv", "text": "id,city\n1,Paris\n"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); let next = s.run("layer.addDataText", &json!({"fileName": "b.csv", "text": "id,city\n1,Paris\n"})).unwrap(); assert!(undone["id"].as_u64() < next["id"].as_u64(), "{undone} then {next}"); } #[test] fn a_saved_selection_is_read_in_range_in_order_and_once() { // A selection edited by hand: a row repeated, out of order, and one past the layer's end. let s = world(); let mut v: Value = serde_json::to_value(&s.project).unwrap(); let layers = v["maps"][0]["layers"].as_array_mut().unwrap(); let i = layers.iter().position(|l| l["name"] == "Countries").unwrap(); layers[i]["selection"] = json!([3, 1, 3, 1_000_000]); let p = Project::from_json(&v.to_string()).unwrap(); let countries = p.map().layers.iter().find(|l| l.name == "Countries").unwrap(); assert_eq!(countries.selection, [1, 3]); // An offline area's snapshot, edited the same way, opens with the selection put right too. let mut s = world(); s.run("offline.download", &json!({"name": "Paris", "extent": [1.0, 48.0, 4.0, 50.0]})).unwrap(); let cities = s.project.offline_areas[0].data.layers.iter_mut().find(|l| l.name == "Cities").unwrap(); let last = cities.dataset.features.len() - 1; cities.selection = vec![last, 0, last, last + 1]; s.run("offline.open", &json!({"name": "Paris"})).unwrap(); let mut want = vec![0, last]; // Row 0 and the last row, once each: one row when the layer has a single feature. want.dedup(); assert_eq!(s.map().layer_by_name("Cities").unwrap().selection, want); } #[test] fn standalone_tables_keep_their_ids_in_the_project_file() { let mut s = world(); for name in ["sites", "sites", "notes"] { s.run("layer.addDataText", &json!({"fileName": format!("{name}.csv"), "text": "id,street\n1,Champ de Mars\n"})).unwrap(); } s.run("table.remove", &json!({"table": "sites"})).unwrap(); let ids: Vec = s.project.tables.iter().map(|t| t.id).collect(); // Saved and read again: the same ids, and the next table gets one that was never used. let text = serde_json::to_string(&s.project).unwrap(); let mut p = Project::from_json(&text).unwrap(); assert_eq!( p.tables.iter().map(|t| (t.id, t.name.clone(), t.features.len())).collect::>(), [(ids[0], "sites".to_string(), 1), (ids[1], "notes".to_string(), 1)] ); let next = p.add_table(rustgis_data::Dataset::new("more", rustgis_data::GeometryType::None, Vec::new())); assert!(next > ids[1], "{next} after {ids:?}"); // A file from before tables had ids, and one with an id repeated by hand: every table gets // an id of its own, and those that had a good one keep it. let mut v: Value = serde_json::from_str(&text).unwrap(); for t in v["tables"].as_array_mut().unwrap() { t.as_object_mut().unwrap().remove("id"); } v.as_object_mut().unwrap().remove("next_table_id"); let old = Project::from_json(&v.to_string()).unwrap(); assert_eq!(old.tables.iter().map(|t| t.id).collect::>(), [1, 2]); v["tables"][0]["id"] = json!(7); v["tables"][1]["id"] = json!(7); let edited = Project::from_json(&v.to_string()).unwrap(); assert_eq!(edited.tables.iter().map(|t| t.id).collect::>(), [7, 8]); // A tool's table put again under its name keeps the id. let stats = json!({"tool": "summaryStatistics", "params": {"input": "Countries", "field": "pop_est", "case": "continent"}}); s.run("gp.run", &stats).unwrap(); let id = s.project.tables.last().map(|t| t.id).unwrap(); s.run("gp.run", &stats).unwrap(); assert_eq!((s.project.tables.len(), s.project.tables.last().map(|t| t.id)), (3, Some(id))); // Commands and tools reach a table by its id as well as by its name, which is the first of // two tables sharing it. for v in [10, 99] { s.run("layer.addDataText", &json!({"fileName": "counts.csv", "text": format!("k,v\na,{v}\n")})).unwrap(); } let info = s.run("info.project", &Value::Null).unwrap(); let counts: Vec = info["tables"].as_array().unwrap().iter().filter(|t| t["name"] == "counts").filter_map(|t| t["id"].as_u64()).collect(); assert_eq!((counts.len(), info["tables"][0]["rows"].as_u64()), (2, Some(1)), "{info}"); // A name exactly as written comes before one that differs in case, for tools as for commands. s.run("layer.addDataText", &json!({"fileName": "Counts.csv", "text": "k,v\na,5\n"})).unwrap(); let upper = s.project.tables.last().map(|t| t.id).unwrap(); assert_eq!(s.table_index(&json!({"table": "Counts"})).ok().and_then(|i| s.project.tables.get(i)).map(|t| t.id), Some(upper)); for (input, sum) in [(json!("counts"), 10.0), (json!("COUNTS"), 10.0), (json!("Counts"), 5.0), (json!(counts[1].to_string()), 99.0)] { s.run("gp.run", &json!({"tool": "summaryStatistics", "params": {"input": input, "field": "v"}})).unwrap(); // The run's table is the last: put again under its name, or added. let out = s.project.tables.last().unwrap(); assert_eq!(out.value(0, out.field_index("SUM_v").unwrap()), &rustgis_data::Value::Float(sum), "{input}"); } // A table named as another's id: the text finds it by its name, and a number is the id. s.run("layer.addDataText", &json!({"fileName": format!("{}.csv", counts[1]), "text": "k,v\na,1\n"})).unwrap(); assert_eq!(s.project.tables.last().map(|t| t.name.clone()), Some(counts[1].to_string())); for (input, sum) in [(json!(counts[1].to_string()), 1.0), (json!(counts[1]), 99.0)] { s.run("gp.run", &json!({"tool": "summaryStatistics", "params": {"input": input, "field": "v"}})).unwrap(); let out = s.project.tables.last().unwrap(); assert_eq!(out.value(0, out.field_index("SUM_v").unwrap()), &rustgis_data::Value::Float(sum), "{input}"); } for input in [json!(9999), json!(-1), json!(1.5)] { assert!(s.run("gp.run", &json!({"tool": "summaryStatistics", "params": {"input": input, "field": "v"}})).is_err()); } let dir = std::env::temp_dir().join(format!("rustgis-table-ids-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("counts.csv"); s.run("share.exportTable", &json!({"table": counts[1], "path": path})).unwrap(); assert!(std::fs::read_to_string(&path).unwrap().contains("99")); std::fs::remove_dir_all(&dir).ok(); assert!(s.run("share.exportTable", &json!({"table": 9999, "path": path})).is_err()); // The table the attribute table shows is forgotten with the table, and with the project. s.table_standalone = Some(id); s.run("table.remove", &json!({"table": ids[0]})).unwrap(); assert_eq!(s.table_standalone, Some(id), "another table's removal leaves it"); s.run("table.remove", &json!({"table": id})).unwrap(); assert_eq!(s.table_standalone, None); s.table_standalone = Some(ids[1]); s.run("project.new", &Value::Null).unwrap(); assert_eq!(s.table_standalone, None); } #[test] fn select_symbolize_undo_redo() { let mut s = world(); let r = s.run("select.byAttributes", &json!({"layer": "Countries", "where": "continent = 'Africa'"})).unwrap(); assert!(r["selected"].as_u64().unwrap() > 40); s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "continent"})).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer.kind_label(), "Unique Values"); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer.kind_label(), "Graduated Colors"); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer.kind_label(), "Unique Values"); } #[test] fn undo_steps_share_the_layers_they_leave_alone() { // #214: each undo step held a full copy of every layer, so a large layer grew by its whole // size with every command (a rename, a style change). let mut s = world(); s.map_mut().layers.iter_mut().for_each(|l| { l.projected(); }); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); s.run("layer.rename", &json!({"layer": "Nations", "name": "States"})).unwrap(); let live = s.map().layer_by_name("States").unwrap(); let id = live.id; for (snap, ..) in &s.undo { let old = snap.maps.iter().flat_map(|m| &m.layers).find(|l| l.id == id).unwrap(); assert!(old.dataset.features.shares(&live.dataset.features), "the snapshot copied the features"); assert_eq!(old.projected_cache().as_ptr(), live.projected_cache().as_ptr(), "the snapshot copied the projected geometry"); } // An edit copies the layer it changes, once, and leaves the snapshot as it was. let before = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}})).unwrap(); let cities = s.map().layer_by_name("Cities").unwrap(); let (snap, ..) = s.undo.last().unwrap(); let old = snap.maps.iter().flat_map(|m| &m.layers).find(|l| l.id == cities.id).unwrap(); assert!(!old.dataset.features.shares(&cities.dataset.features)); assert_eq!((old.dataset.features.len(), cities.dataset.features.len()), (before, before + 1)); let states = s.map().layer_by_name("States").unwrap(); let old = snap.maps.iter().flat_map(|m| &m.layers).find(|l| l.id == id).unwrap(); assert!(old.dataset.features.shares(&states.dataset.features)); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), before); } #[test] fn geoprocessing_adds_layers_and_history() { let mut s = world(); s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 200}})).unwrap(); assert!(s.map().layer_by_name("Cities_Buffer").is_some()); s.run("gp.run", &json!({"tool": "summaryStatistics", "params": {"input": "Countries", "field": "pop_est", "case": "continent"}})) .unwrap(); assert_eq!(s.project.tables.len(), 1); assert!(s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Nope"}})).is_err()); assert_eq!(s.project.gp_history.len(), 2, "a failed run restores the project"); } /// Pivot Table adds a standalone table in one undo step: a row for each continent and a field for /// each income group, holding the population summed over that continent's countries in the group. #[test] fn pivot_table_adds_a_table_in_one_undo_step() { let mut s = world(); let countries = s.map().layer_by_name("Countries").map(|l| l.dataset.clone()).unwrap(); let total: f64 = (0..countries.features.len()).filter_map(|r| countries.value(r, countries.field_index("pop_est").unwrap()).as_f64()).sum(); let params = json!({"input": "Countries", "fields": "continent", "pivot": "income_grp", "value": "pop_est", "statistic": "SUM"}); s.run("gp.run", &json!({"tool": "pivotTable", "params": params})).unwrap(); let t = s.project.tables.last().unwrap(); assert_eq!(t.name, "Countries_Pivot"); assert_eq!(t.fields.first().map(|f| f.name.as_str()), Some("continent")); assert!(t.fields.len() > 2 && t.fields.iter().skip(1).all(|f| f.name.starts_with("income_grp")), "{:?}", t.fields); let cells: f64 = (0..t.features.len()).flat_map(|r| (1..t.fields.len()).filter_map(move |c| t.value(r, c).as_f64())).sum(); assert!((cells - total).abs() < 1.0, "{cells} != {total}"); assert_eq!(s.project.gp_history.len(), 1); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.project.tables.is_empty()); } #[test] fn spatial_join_command_joins_layers_and_records_history() { let mut s = world(); s.run( "gp.spatialJoin", &json!({ "target": "Cities", "join": "Countries", "match": "Intersects", "operation": "Join one to one", "output": "Joined_Cities" }), ) .unwrap(); let l = s.map().layer_by_name("Joined_Cities").unwrap(); assert_eq!(l.dataset.features.len(), s.map().layer_by_name("Cities").unwrap().dataset.features.len()); assert!(l.dataset.field_index("join_count").is_some()); assert_eq!(s.project.gp_history.len(), 1); s.run( "gp.run", &json!({ "tool": "spatialJoin", "params": { "target": "Countries", "join": "Cities", "match": "Contains", "output": "Countries_With_Cities" } }), ) .unwrap(); assert!(s.map().layer_by_name("Countries_With_Cities").is_some()); } #[test] fn editing_features_and_fields() { let mut s = world(); s.run( "edit.addFeature", &json!({"layer": "Cities", "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}, "attributes": {"name": "Null Island"}}), ) .unwrap(); let n = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); assert!( s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": {"type": "LineString", "coordinates": [[0,0],[1,1]]}})).is_err() ); s.run("edit.addField", &json!({"layer": "Cities", "name": "score", "type": "double"})).unwrap(); s.run("edit.calculateField", &json!({"layer": "Cities", "field": "score", "expression": "pop_max / 1000"})).unwrap(); s.run("edit.delete", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), n - 1); } /// A date field is added, edited as ISO text, compared in a where clause, calculated, offered to /// the time slider, and kept as dates through a project save; arithmetic on it is an error, and /// text that isn't a date is reported, not quietly stored as null. #[test] fn date_fields_work_from_adding_to_saving() { use rustgis_data::Value as V; let mut s = world(); s.run("edit.addField", &json!({"layer": "Cities", "name": "founded", "type": "date"})).unwrap(); s.run("edit.addField", &json!({"layer": "Cities", "name": "seen", "type": "datetime"})).unwrap(); let fields = s.run("layer.fields", &json!({"layer": "Cities"})).unwrap(); let types: Vec<&str> = fields.as_array().unwrap().iter().filter_map(|f| f["type"].as_str()).collect(); assert!(types.ends_with(&["Date", "DateTime"]), "{types:?}"); for (row, day) in [(0, "2026-03-05"), (1, "1999-12-31"), (2, "2026-01-01")] { s.run("edit.setValue", &json!({"layer": "Cities", "row": row, "field": "founded", "value": day})).unwrap(); } let selected = s.run("select.byAttributes", &json!({"layer": "Cities", "where": "founded >= '2026-01-01'"})).unwrap(); assert_eq!(selected["selected"], 2); s.run("select.clear", &Value::Null).unwrap(); let err = s.run("edit.calculateField", &json!({"layer": "Cities", "field": "founded", "expression": "founded + 30"})).unwrap_err(); assert!(err.contains("arithmetic on dates"), "{err}"); // Also for a date field named like a literal, which the earlier calculator reads first. s.run("edit.addField", &json!({"layer": "Cities", "name": "true", "type": "date"})).unwrap(); let err = s.run("edit.calculateField", &json!({"layer": "Cities", "field": "founded", "expression": "true + 1"})).unwrap_err(); assert!(err.contains("arithmetic on dates"), "{err}"); s.run("edit.deleteField", &json!({"layer": "Cities", "field": "true"})).unwrap(); // A date into a date-time field is its midnight; text that isn't a date is reported. let mark = s.notice_mark(); s.run("edit.calculateField", &json!({"layer": "Cities", "field": "seen", "expression": "founded"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("Calculate Field")), "clean results give no notice"); // A date-time with a time of day, into a date field by Calculate Field or Paste, keeps its day, and that is said. s.run("edit.setValue", &json!({"layer": "Cities", "row": 0, "field": "seen", "value": "2026-03-05T14:30:00"})).unwrap(); let mark = s.notice_mark(); s.run("edit.calculateField", &json!({"layer": "Cities", "field": "founded", "expression": "seen"})).unwrap(); assert!( s.notices_since(mark).iter().any(|n| n.text == "Calculate Field: 'founded' holds dates, so 1 result kept only its day"), "{:?}", s.notices_since(mark) ); s.run("edit.undo", &Value::Null).unwrap(); s.run("select.byAttributes", &json!({"layer": "Cities", "where": "seen > '2026-03-05T12:00:00'"})).unwrap(); s.run("edit.copy", &Value::Null).unwrap(); s.run("edit.pasteSpecial", &json!({"name": "Moments"})).unwrap(); s.run("edit.deleteField", &json!({"layer": "Moments", "field": "seen"})).unwrap(); s.run("edit.addField", &json!({"layer": "Moments", "name": "seen", "type": "date"})).unwrap(); let mark = s.notice_mark(); s.run("edit.paste", &json!({"layer": "Moments"})).unwrap(); assert!( s.notices_since(mark).iter().any(|n| n.text == "Paste: 'seen' holds dates, so 1 feature kept only its day"), "{:?}", s.notices_since(mark) ); s.run("layer.remove", &json!({"layer": "Moments"})).unwrap(); s.run("edit.setValue", &json!({"layer": "Cities", "row": 0, "field": "seen", "value": "2026-03-05T00:00:00"})).unwrap(); let err = s.run("edit.setValue", &json!({"layer": "Cities", "row": 0, "field": "founded", "value": "soon"})).unwrap_err(); assert_eq!(err, "'soon' isn't a Date value"); // So is any other value a date field can't hold, given as a number. let err = s.run("edit.setValue", &json!({"layer": "Cities", "row": 0, "field": "founded", "value": 20260305})).unwrap_err(); assert_eq!(err, "'20260305' isn't a Date value"); let point = json!({"type": "Point", "coordinates": [0.0, 0.0]}); let err = s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": point, "attributes": {"founded": 20260305}})).unwrap_err(); assert_eq!(err, "field 'founded': '20260305' isn't a Date value"); let added = s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": point, "attributes": {"founded": "2026-03-05"}})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); // `` asks for null, as in the table and edit.setValue. s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": point, "attributes": {"founded": ""}})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); // Pasted dates go in; a value the field can't hold is said, and empty text is no loss. let pasted = |founded: &[&str]| { let features: Vec = founded.iter().map(|f| json!({"type": "Feature", "geometry": point, "properties": {"founded": f}})).collect(); json!({"layer": "Cities", "text": json!({"type": "FeatureCollection", "features": features}).to_string()}) }; let mark = s.notice_mark(); s.run("edit.paste", &pasted(&["2026-03-05", ""])).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("is null in")), "{:?}", s.notices_since(mark)); s.run("edit.undo", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("edit.paste", &pasted(&["2026-03-05", "soon", "March 5"])).unwrap(); assert!( s.notices_since(mark).iter().any(|n| n.text == "Paste: 'founded' is null in 2 features, whose value isn't a Date value"), "{:?}", s.notices_since(mark) ); s.run("edit.undo", &Value::Null).unwrap(); // So does a result of empty text: null, with no notice. s.run("edit.addField", &json!({"layer": "Cities", "name": "n", "type": "long"})).unwrap(); let mark = s.notice_mark(); s.run("edit.calculateField", &json!({"layer": "Cities", "field": "n", "expression": "''"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("Calculate Field")), "{:?}", s.notices_since(mark)); s.run("edit.deleteField", &json!({"layer": "Cities", "field": "n"})).unwrap(); assert!(added["row"].is_u64()); let ds = |s: &Session| s.map().layer_by_name("Cities").unwrap().dataset.clone(); let seen = ds(&s).field_index("seen").unwrap(); assert_eq!(ds(&s).value(0, seen).to_string(), "2026-03-05T00:00:00"); let mark = s.notice_mark(); s.run( "edit.calculateField", &json!({"layer": "Cities", "field": "founded", "expression": "CASE WHEN @row_number = 1 THEN 'soon' ELSE founded END"}), ) .unwrap(); assert!( s.notices_since(mark).iter().any(|n| n.text.contains("'founded' is null in 1 row, whose result isn't a Date value")), "{:?}", s.notices_since(mark) ); assert!(rustgis_map::time::time_fields(&ds(&s)).iter().any(|(f, _)| f == "seen"), "the slider is offered the date field"); // Saved and opened, the values are still dates. // Removed however the test ends, and its own each run (the process id alone can repeat). struct Dir(std::path::PathBuf); impl Drop for Dir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); let dir = Dir(std::env::temp_dir().join(format!("rustgis-dates-{}-{nanos}", std::process::id()))); std::fs::create_dir_all(&dir.0).unwrap(); let path = dir.0.join("dates.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); let (a, b) = (ds(&s), ds(&t)); let founded = b.field_index("founded").unwrap(); assert_eq!(b.fields[founded].ty, rustgis_data::FieldType::Date); assert!(matches!(b.value(1, founded), V::Date(d) if d.to_string() == "1999-12-31")); assert_eq!( (0..a.features.len()).map(|r| a.value(r, seen).clone()).collect::>(), (0..b.features.len()).map(|r| b.value(r, seen).clone()).collect::>() ); } /// A polygon layer stacks fills (a colour, hatch lines, markers on a grid), for the layer or a /// class of its own; junk is refused and numbers held within range; each change undoes, and the /// project keeps them (a layer without them saves as before). #[test] fn fill_layers_stack_and_survive_save_and_undo() { use rustgis_map::Rgba; use rustgis_map::fills::{FillLayer, FillPattern, MAX_FILLS}; let mut s = world(); let before = serde_json::to_string(&s.project).unwrap(); assert!(!before.contains("fill_layers") && !before.contains("class_fills"), "nothing written for layers without them"); let depth = s.undo_depth(); let fills = json!([ {"color": "#E8F0D0", "pattern": "solid"}, {"color": "#336633", "pattern": "hatch", "angle": 45, "spacing": 6, "width": 0.75}, {"pattern": "dots", "marker": "square", "size": 2, "spacing": [8, 6], "displacement": [4, 0], "outline": "#000000", "outlineWidth": 0.5} ]); assert_eq!(s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": fills})).unwrap(), json!({"fills": 3})); assert_eq!(s.undo_depth(), depth + 1, "one undo step"); let got = |s: &Session| s.map().layer_by_name("Countries").unwrap().fill_layers.clone(); let f = got(&s); assert_eq!(f[0], FillLayer { color: Some(Rgba::rgb(0xE8, 0xF0, 0xD0)), pattern: FillPattern::Solid }); assert_eq!(f[1].pattern, FillPattern::Hatch { angle: 45.0, spacing: 6.0, width: 0.75, offset: 0.0 }); let FillPattern::Dots { marker, spacing, outline, .. } = f[2].pattern else { panic!("{f:?}") }; assert_eq!((marker, spacing, outline, f[2].color), (rustgis_map::Marker::Square, [8.0, 6.0], Some(Rgba::rgb(0, 0, 0)), None)); // Kept with the project, and undone. let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!(got(&reopened), f); s.run("edit.undo", &json!({})).unwrap(); assert!(got(&s).is_empty()); // Held within range; a number given for both ways. s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "hatch", "spacing": 1e300, "width": -4, "angle": -45}, {"pattern": "dots", "spacing": 1e9}]})) .unwrap(); let f = got(&s); assert_eq!(f[0].pattern, FillPattern::Hatch { angle: 315.0, spacing: 500.0, width: 0.0, offset: 0.0 }); assert!(matches!(f[1].pattern, FillPattern::Dots { spacing: [500.0, 500.0], .. }), "{f:?}"); // A character of a font as the dots (a QGIS font marker's), kept with its family; not with a // shape too. s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "dots", "char": "★", "font": "Noto Sans"}]})).unwrap(); let FillPattern::Dots { marker: rustgis_map::Marker::Char(g), .. } = got(&s)[0].pattern else { panic!("{:?}", got(&s)) }; assert_eq!((g.ch, g.font.as_str()), ('★', "Noto Sans")); assert!( s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "dots", "marker": "square", "char": "x"}]})).is_err() ); // A class's own (Unique Values), then back to the layer's. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "continent"})).unwrap(); let value = match &s.map().layer_by_name("Countries").unwrap().renderer { rustgis_map::Renderer::Unique { classes, .. } => classes[0].0.clone(), r => panic!("{r:?}"), }; s.run("layer.fillLayers", &json!({"layer": "Countries", "value": value, "fills": [{"pattern": "hatch"}]})).unwrap(); let class_fills = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { rustgis_map::Renderer::Unique { class_fills, .. } => class_fills.clone(), _ => Default::default(), }; assert_eq!(class_fills(&s).get(&value).map(Vec::len), Some(1)); s.run("layer.fillLayers", &json!({"layer": "Countries", "value": value, "fills": []})).unwrap(); assert!(class_fills(&s).is_empty()); assert!(s.run("layer.fillLayers", &json!({"layer": "Countries", "value": "no such class", "fills": []})).is_err()); // Junk, and a layer that isn't polygons. let many: Vec = (0..=MAX_FILLS).map(|_| json!({})).collect(); for bad in [ json!({"layer": "Countries"}), json!({"layer": "Countries", "fills": "hatch"}), json!({"layer": "Countries", "fills": many}), json!({"layer": "Countries", "fills": [7]}), json!({"layer": "Countries", "fills": [{"pattern": "stripes"}]}), json!({"layer": "Countries", "fills": [{"color": "red"}]}), json!({"layer": "Countries", "fills": [{"pattern": "hatch", "spacing": "wide"}]}), json!({"layer": "Countries", "fills": [{"pattern": "dots", "spacing": [1]}]}), json!({"layer": "Countries", "fills": [{"pattern": "dots", "marker": "blob"}]}), json!({"layer": "Rivers", "fills": []}), ] { assert!(s.run("layer.fillLayers", &bad).is_err(), "{bad}"); } } /// Gradient and shapeburst fills: their colours, stops and how they run, kept with the project; /// junk refused, numbers held within range. #[test] fn gradient_and_shapeburst_fills_are_set_and_kept() { use rustgis_map::Rgba; use rustgis_map::fills::{FillPattern, GradientPoint, GradientShape, MAX_GRADIENT_POINT, Spread, Stop}; let mut s = world(); let fills = json!([ {"color": "#FF0000", "pattern": "gradient", "color2": "#0000FF", "shape": "radial", "from": "centroid", "to": [1, 0.5], "spread": "reflect", "angle": 30, "stops": [[0.5, "#00FF00"]]}, {"color": "#2B6ADB", "pattern": "shapeburst", "color2": "#6A94DB", "distance": 14, "blur": 10, "ignoreRings": true, "discrete": true} ]); assert_eq!(s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": fills})).unwrap(), json!({"fills": 2})); let got = |s: &Session| s.map().layer_by_name("Countries").unwrap().fill_layers.clone(); let f = got(&s); let FillPattern::Gradient { shape, color2, stops, from, to, spread, angle, in_view, discrete } = f[0].pattern else { panic!("{f:?}") }; assert_eq!( (shape, color2, from, to, spread, angle, in_view, discrete), ( GradientShape::Radial, Rgba::rgb(0, 0, 255), GradientPoint::CENTROID, GradientPoint::At([1.0, 0.5]), Spread::Reflect, 30.0, false, false ) ); assert_eq!(stops.as_slice(), &[Stop { at: 0.5, color: Rgba::rgb(0, 255, 0) }]); assert_eq!( f[1].pattern, FillPattern::Shapeburst { color2: Rgba::rgb(0x6A, 0x94, 0xDB), stops: Default::default(), discrete: true, distance: Some(14.0), blur: 10, ignore_rings: true } ); // Kept with the project. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains(r#""from":"centroid""#), "{text}"); assert_eq!(got(&Session::new(serde_json::from_str(&text).unwrap())), f); // Defaults: QGIS's, top middle to bottom middle, to white; a shapeburst over the whole shape. s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "gradient"}, {"pattern": "shapeburst"}]})).unwrap(); let f = got(&s); assert!( matches!(f[0].pattern, FillPattern::Gradient { shape: GradientShape::Linear, from: GradientPoint::At([0.5, 0.0]), to: GradientPoint::At([0.5, 1.0]), color2, .. } if color2 == Rgba::rgb(255, 255, 255)), "{f:?}" ); assert!(matches!(f[1].pattern, FillPattern::Shapeburst { distance: None, blur: 0, .. }), "{f:?}"); // Held within range. s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "gradient", "from": [1e300, -1e300], "angle": -90, "stops": [[7, "#000000"], [-1, "#FFFFFF"]]}]})).unwrap(); let FillPattern::Gradient { from, angle, stops, .. } = got(&s)[0].pattern else { panic!() }; assert_eq!((from, angle), (GradientPoint::At([MAX_GRADIENT_POINT, -MAX_GRADIENT_POINT]), 270.0)); assert_eq!(stops.as_slice().iter().map(|s| s.at).collect::>(), vec![0.0, 1.0], "in order, within 0 to 1"); for bad in [ json!({"pattern": "gradient", "shape": "spiral"}), json!({"pattern": "gradient", "spread": 2}), json!({"pattern": "gradient", "from": "middle"}), json!({"pattern": "gradient", "from": [0.5]}), json!({"pattern": "gradient", "stops": [[0.5]]}), json!({"pattern": "gradient", "stops": [[0.5, "green"]]}), json!({"pattern": "gradient", "stops": (0..20).map(|i| json!([i as f64 / 20.0, "#000000"])).collect::>()}), json!({"pattern": "gradient", "inView": "yes"}), json!({"pattern": "shapeburst", "blur": 99}), json!({"pattern": "shapeburst", "distance": -1}), json!({"pattern": "shapeburst", "color2": "blue"}), ] { assert!(s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [bad]})).is_err(), "{bad}"); } } #[test] fn line_strokes_stack_and_survive_save_and_undo() { use rustgis_map::{LineCap, LineJoin, LineStroke, Rgba}; let mut s = world(); let depth = s.undo_depth(); // A black casing under an orange core, both shifted 8.5 pt right (#478). let strokes = json!([ {"color": "#000000", "width": 11.5, "offset": 8.5, "cap": "round", "join": "round"}, {"color": "#FF8C00", "width": 7, "offset": 8.5, "cap": "flat", "join": "miter"}, {"offset": -2} ]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); let want = vec![ LineStroke { color: Some(Rgba::rgb(0, 0, 0)), width: Some(11.5), offset: 8.5, cap: LineCap::Round, join: LineJoin::Round, level: None, dash: None, markers: None, }, LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), width: Some(7.0), offset: 8.5, cap: LineCap::Butt, join: LineJoin::Miter, level: None, dash: None, markers: None, }, LineStroke { offset: -2.0, ..LineStroke::default() }, ]; assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes, want); assert_eq!(s.undo_depth(), depth + 1); // Kept in the project file, and drawn in an export. let dir = std::env::temp_dir().join(format!("rustgis-strokes-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("strokes.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(t.map().layer_by_name("Rivers").unwrap().line_strokes, want); let png = dir.join("map.png"); t.run("share.exportMap", &json!({"path": png.display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); // Out-of-range numbers are brought into range; junk is refused and changes nothing. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"width": 1e9, "offset": -1e9}]})).unwrap(); let st = s.map().layer_by_name("Rivers").unwrap().line_strokes.clone(); assert_eq!((st[0].width, st[0].offset), (Some(50.0), -100.0)); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"width": 1e300, "offset": 1e300}]})).unwrap(); let st = s.map().layer_by_name("Rivers").unwrap().line_strokes.clone(); assert_eq!((st[0].width, st[0].offset), (Some(50.0), 100.0)); let depth = s.undo_depth(); for bad in [ json!({"layer": "Rivers"}), json!({"layer": "Rivers", "strokes": [{"cap": "pointy"}]}), json!({"layer": "Rivers", "strokes": [{"join": "zigzag"}]}), json!({"layer": "Rivers", "strokes": [{"cap": 5}]}), json!({"layer": "Rivers", "strokes": [{"join": true}]}), json!({"layer": "Rivers", "strokes": [{"color": "orange"}]}), json!({"layer": "Rivers", "strokes": [{"width": "wide"}]}), json!({"layer": "Rivers", "strokes": [7]}), json!({"layer": "Rivers", "strokes": vec![json!({}); 17]}), // Polygons have no strokes of their own (points have marks). json!({"layer": "Countries", "strokes": []}), ] { assert!(s.run("layer.lineStrokes", &bad).is_err(), "{bad}"); } assert_eq!(s.undo_depth(), depth); s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": []})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); // [] goes back to the single stroke; undo brings the stack back. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": []})).unwrap(); assert!(s.map().layer_by_name("Rivers").unwrap().line_strokes.is_empty()); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1); } #[test] fn line_strokes_take_markers_and_hash_marks_along_the_line() { use rustgis_map::{LineMark, LineMarkers, Marker, Placements, Rgba}; let mut s = world(); // A river: its line, triangles at both ends turned with it, and ticks across it. let strokes = json!([ {"color": "#1E5AA8", "width": 2}, {"color": "#1E5AA8", "markers": {"mark": "Triangle", "size": 8, "at": ["first", "last"], "outline": "#FFFFFF"}, "width": 1}, {"color": "#1E5AA8", "width": 1, "markers": {"mark": "hash", "size": 6, "interval": 10, "angle": 30, "rotate": false}} ]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); let st = s.map().layer_by_name("Rivers").unwrap().line_strokes.clone(); assert_eq!(st[0].markers, None); let ends = LineMarkers { mark: LineMark::Marker(Marker::Triangle), size: 8.0, at: Placements::FIRST.with(Placements::LAST), outline: Some(Rgba::rgb(255, 255, 255)), ..LineMarkers::default() }; assert_eq!(st[1].markers, Some(ends)); let ticks = LineMarkers { mark: LineMark::Hash, size: 6.0, interval: 10.0, angle: 30.0, rotate: false, ..LineMarkers::default() }; assert_eq!(st[2].markers, Some(ticks)); // A single place is a list of one; out-of-range numbers are brought into range. let one = json!([{"markers": {"at": "middle", "size": 1e9, "interval": 0, "angle": 450}}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": one})).unwrap(); let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap(); assert_eq!((m.at, m.size, m.interval, m.angle), (Placements::MIDDLE, 100.0, 0.5, 90.0)); // Drawn in an export. let dir = std::env::temp_dir().join(format!("rustgis-markers-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); // Junk is refused, saying where, and changes nothing. let depth = s.undo_depth(); for (bad, says) in [ (json!({"markers": 5}), "stroke 1: markers: an object"), (json!({"markers": {"mark": "hexagon"}}), "unknown mark 'hexagon'"), (json!({"markers": {"mark": 3}}), "mark is"), (json!({"markers": {"at": "sideways"}}), "at is a placement"), (json!({"markers": {"at": []}}), "at is a placement"), (json!({"markers": {"at": ["first", 2]}}), "at is a placement"), (json!({"markers": {"rotate": "yes"}}), "rotate is true or false"), (json!({"markers": {"size": "big"}}), "size is a number"), (json!({"markers": {"outline": "white"}}), "outline must be"), ] { let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [bad]})).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } assert_eq!(s.undo_depth(), depth); } #[test] fn a_character_of_a_font_is_a_marker_on_points_and_along_lines() { use rustgis_map::{Glyph, LineMark, Marker}; let mut s = world(); let marker = |s: &Session| s.map().layer_by_name("Cities").unwrap().symbol(0).marker; s.run("layer.marker", &json!({"layer": "Cities", "char": "★x", "font": "DejaVu Sans", "size": 14})).unwrap(); assert_eq!(marker(&s), Marker::Char(Glyph::new('★', "DejaVu Sans"))); // Another character keeps the family; a family alone changes the character's. s.run("layer.marker", &json!({"layer": "Cities", "char": "A"})).unwrap(); assert_eq!(marker(&s), Marker::Char(Glyph::new('A', "DejaVu Sans"))); s.run("layer.marker", &json!({"layer": "Cities", "font": ""})).unwrap(); assert_eq!(marker(&s), Marker::Char(Glyph::new('A', ""))); // Drawn in an export, a family this machine lacks in the label font. let dir = std::env::temp_dir().join(format!("rustgis-chars-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("layer.marker", &json!({"layer": "Cities", "font": "No Such Family"})).unwrap(); let strokes = json!([{"color": "#1E5AA8", "markers": {"mark": "character", "char": "»", "font": "DejaVu Sans", "size": 8, "interval": 20}}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap(); assert_eq!(m.mark, LineMark::Marker(Marker::Char(Glyph::new('»', "DejaVu Sans")))); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); // A shape goes back to the shape. s.run("layer.marker", &json!({"layer": "Cities", "shape": "square"})).unwrap(); assert_eq!(marker(&s), Marker::Square); let depth = s.undo_depth(); for (bad, says) in [ (json!({"layer": "Cities", "char": ""}), "char is a character"), (json!({"layer": "Cities", "char": 7}), "char is a character"), (json!({"layer": "Cities", "char": "a", "shape": "star"}), "not both"), ] { let err = s.run("layer.marker", &bad).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "hash", "char": "x"}}]})).unwrap_err(); assert!(err.contains("hash marks take no char"), "{err}"); let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "character"}}]})).unwrap_err(); assert!(err.contains("needs its char"), "{err}"); // A shape with a char is refused, as by `layer.marker`. let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "circle", "char": "x"}}]})).unwrap_err(); assert!(err.contains("not both"), "{err}"); assert_eq!(s.undo_depth(), depth); } #[test] fn a_point_takes_stacked_marks_and_turns_by_a_field() { use rustgis_map::{Glyph, LineMark, Marker}; let mut s = world(); // A white disc under a black character, and a red square 6 pt right of the point. let marks = json!([ {"color": "#FFFFFF", "markers": {"mark": "circle", "size": 14}}, {"color": "#000000", "markers": {"mark": "character", "char": "A", "size": 12}}, {"color": "#FF0000", "markers": {"mark": "square", "size": 4, "offsetAlong": 6}} ]); let mark = s.notice_mark(); s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": marks})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); let st = s.map().layer_by_name("Cities").unwrap().line_strokes.clone(); assert_eq!(st.len(), 3); assert_eq!(st[1].markers.unwrap().mark, LineMark::Marker(Marker::Char(Glyph::new('A', "")))); assert_eq!(st[2].markers.unwrap().offset_along, 6.0); // A line stroke would draw nothing at a point. let err = s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": [{"width": 2}]})).unwrap_err(); assert!(err.contains("stroke 1: a point layer's strokes are markers"), "{err}"); let err = s.run("layer.lineStrokes", &json!({"layer": "Countries", "strokes": marks})).unwrap_err(); assert!(err.contains("line and point layers"), "{err}"); // Turned by a numeric field, counterclockwise. s.run("layer.markerRotation", &json!({"layer": "Cities", "field": "pop_max", "counterclockwise": true})).unwrap(); let l = s.map().layer_by_name("Cities").unwrap(); let r = l.marker_rotation.clone().unwrap(); assert_eq!((r.field.as_str(), r.counterclockwise), ("pop_max", true)); let fi = l.dataset.field_index("pop_max").unwrap(); let v = l.dataset.value(0, fi).as_f64().unwrap(); assert_eq!(l.marker_turn(0), -(v.rem_euclid(360.0) as f32)); // Drawn in an export. let dir = std::env::temp_dir().join(format!("rustgis-marks-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); let depth = s.undo_depth(); for (bad, says) in [ (json!({"layer": "Cities", "field": "name"}), "not a numeric field"), (json!({"layer": "Cities", "field": "nowhere"}), "no field named"), (json!({"layer": "Cities", "field": 3}), "field is a field's name"), (json!({"layer": "Rivers", "field": "pop_max"}), "point layers"), ] { let err = s.run("layer.markerRotation", &bad).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } assert_eq!(s.undo_depth(), depth); // None turns them back. s.run("layer.markerRotation", &json!({"layer": "Cities", "field": null})).unwrap(); assert!(s.map().layer_by_name("Cities").unwrap().marker_rotation.is_none()); } #[test] fn a_symbol_is_set_by_expressions() { let mut s = world(); let r = s .run("layer.symbolBy", &json!({"layer": "Cities", "size": "scale_linear(pop_max, 0, 2e7, 4, 20)", "fill": "if(pop_max > 1e7, '#ff0000', NULL)", "angle": "45"})) .unwrap(); assert_eq!(r["angle"], "45"); let l = s.map().layer_by_name("Cities").unwrap(); let fi = l.dataset.field_index("pop_max").unwrap(); let big = (0..l.dataset.features.len()).find(|r| l.dataset.value(*r, fi).as_f64().is_some_and(|v| v > 1e7)).unwrap(); let st = l.styler(None); assert_eq!(st.symbol(big).fill, rustgis_map::Rgba::new(255, 0, 0, 255)); assert!(st.symbol(big).size > 4.0); assert_eq!(st.marker_turn(big), 45.0); // Drawn in an export. let dir = std::env::temp_dir().join(format!("rustgis-symbol-by-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); let depth = s.undo_depth(); for (bad, says) in [ (json!({"layer": "Cities", "size": "nosuch + 1"}), "size: "), (json!({"layer": "Cities", "fill": "'red' ||"}), "fill: "), (json!({"layer": "Cities", "stroke": 3}), "stroke is an expression"), (json!({"layer": "Rivers", "angle": "10"}), "angle works on point layers"), (json!({"layer": "Rivers", "size": "4"}), "size works on point layers"), ] { let err = s.run("layer.symbolBy", &bad).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } assert_eq!(s.undo_depth(), depth); // A line's width by expression; the Cities keep theirs. s.run("layer.symbolBy", &json!({"layer": "Rivers", "strokeWidth": "2.5"})).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().symbol_by.angle.as_deref(), Some("45")); // A key left out is kept; null removes one, and undo brings it back. s.run("layer.symbolBy", &json!({"layer": "Cities", "angle": null, "fill": ""})).unwrap(); let by = &s.map().layer_by_name("Cities").unwrap().symbol_by; assert!(by.size.is_some() && by.fill.is_none() && by.angle.is_none()); s.undo(); assert_eq!(s.map().layer_by_name("Cities").unwrap().symbol_by.angle.as_deref(), Some("45")); } /// Applied again by its own expression, an imported rule style keeps as many classes as it has /// (past the first 64 values, up to the 200 an import makes); classes by an expression reading the map's scale are said to be /// the values given without one. #[test] fn unique_values_by_expression_keep_their_classes_and_say_scales() { let mut s = world(); let mut ds = rustgis_data::Dataset::new( "many", rustgis_data::GeometryType::Point, vec![rustgis_data::Field::new("v", rustgis_data::FieldType::Integer)], ); let n = rustgis_map::symbology::MAX_EXPRESSION_CLASSES as i64; for i in 0..n { ds.push(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(i as f64 / 10.0, 1.0))), vec![rustgis_data::Value::Int(i)]); } let many = s.map_mut().add_dataset(ds); let rules: String = (0..n).map(|i| format!(r#""#)).collect(); let qml = format!( r#"{rules}"# ); s.run("style.importQgis", &json!({"layer": many, "text": qml})).unwrap(); let classes = |s: &Session| match &s.map().layer(many).unwrap().renderer { rustgis_map::Renderer::Unique { classes, expression: Some(_), .. } => classes.len(), r => panic!("{r:?}"), }; assert_eq!(classes(&s), n as usize); let mark = s.notice_mark(); s.run("layer.symbology", &json!({"layer": many, "type": "unique", "ramp": "Ember"})).unwrap(); assert_eq!(classes(&s), n as usize, "every rule keeps its class, up to the limit"); let said = |s: &Session, what: &str| s.notices_since(mark).iter().any(|n| n.text.contains(what)); assert!(!said(&s, "past the first"), "{:?}", s.notices_since(mark)); assert!(!said(&s, "without a map scale")); // An expression of its own keeps the first 64, and says so. s.run("layer.symbology", &json!({"layer": many, "type": "unique", "expression": "'k' || (1000 + v)"})).unwrap(); assert_eq!(classes(&s), rustgis_map::symbology::MAX_UNIQUE); assert!(said(&s, "past the first 64")); // One reading the map's scale is classed without one, and says so. let mark = s.notice_mark(); s.run( "layer.symbology", &json!({"layer": many, "type": "unique", "expression": "CASE WHEN @map_scale < 50000 THEN 'detail' ELSE 'overview' END"}), ) .unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("without a map scale")), "{:?}", s.notices_since(mark)); // Its classes are what it gives with no scale: 'detail' only at some scales isn't one. let rustgis_map::Renderer::Unique { classes: kept, .. } = &s.map().layer(many).unwrap().renderer else { panic!() }; assert_eq!(kept.iter().map(|c| c.0.as_str()).collect::>(), ["overview"]); // So too with the scale written right after a word, which the expression still reads. let mark = s.notice_mark(); s.run( "layer.symbology", &json!({"layer": many, "type": "unique", "expression": "CASE WHEN@map_scale < 50000 THEN 'detail' ELSE 'overview' END"}), ) .unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("without a map scale")), "{:?}", s.notices_since(mark)); let rustgis_map::Renderer::Unique { classes: kept, .. } = &s.map().layer(many).unwrap().renderer else { panic!() }; assert_eq!(kept.iter().map(|c| c.0.as_str()).collect::>(), ["overview"]); } /// A layer of points read from GeoJSON, one for each value given, in that order: the integer /// field `n` holds it, the text field `code` its digits, the text field `name` "h" and them. fn numbered_points(values: &[i64]) -> Session { let feats: Vec = values .iter() .enumerate() .map(|(i, n)| { json!({"type": "Feature", "geometry": {"type": "Point", "coordinates": [i as f64 * 0.1, 0.0]}, "properties": {"n": n, "code": n.to_string(), "name": format!("h{n}")}}) }) .collect(); let text = json!({"type": "FeatureCollection", "features": feats}).to_string(); let (ds, _) = rustgis_data::io::read_geojson(&text, "Herds").unwrap(); let ty = |f: &str| ds.fields[ds.field_index(f).unwrap()].ty; assert_eq!( (ty("n"), ty("code"), ty("name")), (rustgis_data::FieldType::Integer, rustgis_data::FieldType::Text, rustgis_data::FieldType::Text) ); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s } fn herd_legend(s: &Session) -> Vec { s.map().layer_by_name("Herds").unwrap().renderer.legend().into_iter().map(|e| e.label).collect() } fn herd_classes(s: &Session) -> Vec { match &s.map().layer_by_name("Herds").unwrap().renderer { rustgis_map::Renderer::Unique { classes, .. } => classes.iter().map(|c| c.0.clone()).collect(), r => panic!("{r:?}"), } } /// #1340: Unique Values on an integer field lists its classes and legend by number, as on a text /// field holding only numbers; a text field of other text keeps text order. #[test] fn unique_values_on_a_number_field_are_in_numeric_order() { let herds = [10153, 12490, 14443, 1741, 1910, 19947, 2723, 3076, 3672, 4060, 5985, 7636, 9081, 9627]; let mut s = numbered_points(&herds); let mut sorted = herds.to_vec(); sorted.sort_unstable(); let numbers: Vec = sorted.iter().map(i64::to_string).collect(); let mut legend = numbers.clone(); legend.push("".into()); for field in ["n", "code"] { let mark = s.notice_mark(); s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": field})).unwrap(); assert_eq!(herd_classes(&s), numbers, "{field}"); assert_eq!(herd_legend(&s), legend, "{field}"); // Every value has a class: nothing to say. assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("past the first")), "{:?}", s.notices_since(mark)); } // A renamed class keeps its value's row. s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": "n"})).unwrap(); s.run("layer.classStyle", &json!({"layer": "Herds", "value": "9627", "label": "Most"})).unwrap(); let mut renamed = legend.clone(); renamed[sorted.iter().position(|v| *v == 9627).unwrap()] = "Most".into(); assert_eq!(herd_legend(&s), renamed); // Text: text order. s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": "name"})).unwrap(); let mut names: Vec = herds.iter().map(|n| format!("h{n}")).collect(); names.sort(); assert_eq!(herd_classes(&s), names); names.push("".into()); assert_eq!(herd_legend(&s), names); } /// More values than Unique Values keeps: the first by number (or by text, for text) are classes, /// and the features past them are said, with how many, whether classed by a field or an /// expression. #[test] fn unique_values_on_a_field_say_what_is_past_their_classes() { let max = rustgis_map::symbology::MAX_UNIQUE; // 100 values, each on two features, in no order. let values: Vec = (0..100).rev().chain(0..100).map(|i| i * 7).collect(); let mut s = numbered_points(&values); let past = 2 * (100 - max); let first: Vec = (0..max as i64).map(|i| (i * 7).to_string()).collect(); for field in ["n", "code"] { let mark = s.notice_mark(); s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": field})).unwrap(); assert_eq!(herd_classes(&s), first, "{field}"); let said = format!( "Herds: {past} features whose value is past the first {max} values of {field} (by number) are drawn as " ); assert!(s.notices_since(mark).iter().any(|n| n.text == said), "{said}\n{:?}", s.notices_since(mark)); } let mark = s.notice_mark(); s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "expression": "n"})).unwrap(); assert_eq!(herd_classes(&s), first); let said = format!("Herds: {past} features whose value is past the first {max} values the expression gives (by number)"); assert!(s.notices_since(mark).iter().any(|n| n.text.starts_with(&said)), "{:?}", s.notices_since(mark)); // Text: the first in text order. let mark = s.notice_mark(); s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": "name"})).unwrap(); let mut names: Vec = (0..100).map(|i| format!("h{}", i * 7)).collect(); names.sort(); assert_eq!(herd_classes(&s), names[..max]); let said = format!("Herds: {past} features whose value is past the first {max} values of name (in text order)"); assert!(s.notices_since(mark).iter().any(|n| n.text.starts_with(&said)), "{:?}", s.notices_since(mark)); // A selection of fewer values says nothing. let mark = s.notice_mark(); s.run("layer.definitionQuery", &json!({"layer": "Herds", "where": "n < 300"})).unwrap(); s.run("layer.symbology", &json!({"layer": "Herds", "type": "unique", "field": "n"})).unwrap(); assert_eq!(herd_classes(&s).len(), 43); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("past the first")), "{:?}", s.notices_since(mark)); } /// Subtypes draw the layer by Unique Values of the subtype field: past 64 values, the features /// drawn as are said too. #[test] fn subtypes_say_what_is_past_their_classes() { let max = rustgis_map::symbology::MAX_UNIQUE; let subtypes = json!([{"code": 0, "name": "None"}, {"code": 7, "name": "Seven"}]); let past = |s: &Session, mark| { s.notices_since(mark).iter().filter(|n| n.text.contains("past the first")).map(|n| n.text.clone()).collect::>() }; // 100 values, each on two features. let values: Vec = (0..100).rev().chain(0..100).map(|i| i * 7).collect(); let mut s = numbered_points(&values); let mark = s.notice_mark(); s.run("data.subtypes", &json!({"layer": "Herds", "field": "n", "subtypes": subtypes})).unwrap(); let first: Vec = (0..max as i64).map(|i| (i * 7).to_string()).collect(); assert_eq!(herd_classes(&s), first); let said = format!( "Herds: {} features whose value is past the first {max} values of n (by number) are drawn as ", 2 * (100 - max) ); assert_eq!(past(&s, mark), [said]); // Only the features a definition query lets through are drawn, so only they are counted; // the classes still come from every feature. s.run("layer.definitionQuery", &json!({"layer": "Herds", "where": "n >= 600"})).unwrap(); let mark = s.notice_mark(); s.run("data.subtypes", &json!({"layer": "Herds", "field": "n", "subtypes": subtypes})).unwrap(); assert_eq!(herd_classes(&s), first, "classes from every feature"); let said = format!( "Herds: {} features whose value is past the first {max} values of n (by number) are drawn as ", 2 * (100 - 600 / 7 - 1) ); assert_eq!(past(&s, mark), [said]); s.run("layer.definitionQuery", &json!({"layer": "Herds", "where": "n < 300"})).unwrap(); let mark = s.notice_mark(); s.run("data.subtypes", &json!({"layer": "Herds", "field": "n", "subtypes": subtypes})).unwrap(); assert!(past(&s, mark).is_empty(), "every drawn feature has a class"); s.run("layer.definitionQuery", &json!({"layer": "Herds", "where": ""})).unwrap(); // Not drawn by them, or with fewer values: nothing to say. let mark = s.notice_mark(); s.run("data.subtypes", &json!({"layer": "Herds", "field": "n", "subtypes": subtypes, "symbolize": false})).unwrap(); assert!(past(&s, mark).is_empty()); let mut s = numbered_points(&[0, 7, 14, 7]); let mark = s.notice_mark(); s.run("data.subtypes", &json!({"layer": "Herds", "field": "n", "subtypes": subtypes})).unwrap(); assert_eq!(herd_classes(&s), ["0", "7", "14"]); assert!(past(&s, mark).is_empty()); } #[test] fn unique_values_class_by_an_expression() { let mut s = world(); let e = "CASE WHEN pop_max >= 10000000 THEN 'mega' ELSE 'city' END"; // Classed by a field that isn't the first, which clearing the expression comes back to. let by = s.map().layer_by_name("Cities").unwrap().dataset.fields.last().unwrap().name.clone(); assert_ne!(Some(&by), s.map().layer_by_name("Cities").unwrap().dataset.fields.first().map(|f| &f.name)); s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "field": by})).unwrap(); s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "expression": e})).unwrap(); let keys = |s: &Session| match &s.map().layer_by_name("Cities").unwrap().renderer { rustgis_map::Renderer::Unique { classes, .. } => classes.iter().map(|c| c.0.clone()).collect::>(), r => panic!("{r:?}"), }; let expression = |s: &Session| s.map().layer_by_name("Cities").unwrap().renderer.expression().map(str::to_string); assert_eq!((keys(&s), expression(&s).as_deref()), (vec!["city".to_string(), "mega".to_string()], Some(e))); let depth = s.undo_depth(); for (bad, says) in [ (json!({"layer": "Cities", "type": "unique", "expression": "nosuch + 1"}), "nosuch"), (json!({"layer": "Cities", "type": "unique", "expression": e, "field": "name"}), "not both"), (json!({"layer": "Cities", "type": "graduated", "expression": e}), "only Unique Values"), (json!({"layer": "Cities", "type": "unique", "expression": 5}), "expression is text"), ] { let err = s.run("layer.symbology", &bad).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } assert_eq!(s.undo_depth(), depth); // Applied again with neither a field nor an expression (another colour scheme): the same // expression. Null or blank goes back to a field; undo brings the expression back. s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "ramp": "Ember"})).unwrap(); assert_eq!(expression(&s).as_deref(), Some(e)); s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "expression": null})).unwrap(); assert!(expression(&s).is_none()); assert_eq!(s.map().layer_by_name("Cities").unwrap().renderer.field(), Some(by.as_str()), "back to the field it had"); s.undo(); assert_eq!(expression(&s).as_deref(), Some(e)); s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "expression": " "})).unwrap(); assert!(expression(&s).is_none(), "blank text goes back to the field too"); assert_eq!(s.map().layer_by_name("Cities").unwrap().renderer.field(), Some(by.as_str())); s.undo(); assert_eq!(expression(&s).as_deref(), Some(e)); // A style shared to the web says it draws one symbol; a map draws the classes. let dir = std::env::temp_dir().join(format!("rustgis-unique-by-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("share.webStyle", &json!({"layer": "Cities", "path": dir.join("cities.json").display().to_string()})).unwrap(); assert!(s.notices.iter().any(|n| n.text.contains("classes by expression aren't in it")), "{:?}", s.notices.last()); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); // More values than classes: the features past them are said, with how many; a few values // say nothing. let past = |s: &Session| s.notices.iter().filter(|n| n.text.contains("past the first")).count(); assert_eq!(past(&s), 0); s.run("layer.symbology", &json!({"layer": "Cities", "type": "unique", "expression": "name"})).unwrap(); let l = s.map().layer_by_name("Cities").unwrap(); let (_, rest) = rustgis_map::Renderer::unique_by_counted(&l.dataset, &[], "name", rustgis_map::Ramp::Harbour, l.renderer.base_symbol()).unwrap(); assert!(rest.features > 0 && !rest.by_number); let rest = rest.features; let said = format!("Cities: {rest} features whose value is past the first {} values the expression gives", rustgis_map::symbology::MAX_UNIQUE); assert!(s.notices.iter().any(|n| n.text.starts_with(&said)), "{:?}", s.notices.last()); s.undo(); // Shared to the web, the one symbol is one that draws: not a rest without a fill or an outline // width. let cities = s.map().layer_by_name("Cities").unwrap().id; let l = s.map_mut().layer_mut(cities).unwrap(); let rustgis_map::Renderer::Unique { other, classes, .. } = &mut l.renderer else { panic!() }; (other.fill, other.stroke, other.stroke_width) = (rustgis_map::Rgba::new(0, 0, 0, 0), rustgis_map::Rgba::rgb(9, 9, 9), 0.0); let first = classes[0].1.fill.hex(); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("one.json"); s.run("share.webStyle", &json!({"layer": "Cities", "path": path.display().to_string()})).unwrap(); let style: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); let circle = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "circle").unwrap(); assert_eq!(circle["paint"]["circle-color"], json!(first)); assert!(s.notices.iter().any(|n| n.text.contains("one symbol (its first class's)")), "{:?}", s.notices.last()); // A point's rest of no size draws nothing either, however it is filled. let l = s.map_mut().layer_mut(cities).unwrap(); let rustgis_map::Renderer::Unique { other, .. } = &mut l.renderer else { panic!() }; (other.fill, other.size) = (rustgis_map::Rgba::rgb(9, 9, 9), 0.0); s.run("share.webStyle", &json!({"layer": "Cities", "path": path.display().to_string()})).unwrap(); let style: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); let circle = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "circle").unwrap(); assert_eq!(circle["paint"]["circle-color"], json!(first)); // A line's colour is its stroke's, as the map draws it. let mut ds = rustgis_data::Dataset::new("ways", rustgis_data::GeometryType::Polyline, vec![]); ds.push(Some(rustgis_data::Geometry::Line(vec![vec![rustgis_data::Coord::new(0.0, 0.0), rustgis_data::Coord::new(1.0, 1.0)]])), vec![]); let ways = s.map_mut().add_dataset(ds); let l = s.map_mut().layer_mut(ways).unwrap(); let mut sym = l.renderer.base_symbol(); (sym.fill, sym.stroke) = (rustgis_map::Rgba::rgb(1, 2, 3), rustgis_map::Rgba::rgb(200, 100, 50)); l.renderer = rustgis_map::Renderer::Single(sym); s.run("share.webStyle", &json!({"layer": ways, "path": path.display().to_string()})).unwrap(); let style: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); let line = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "line").unwrap(); assert_eq!(line["paint"]["line-color"], json!(sym.stroke.hex())); // By expression, a line's rest with a fill but no stroke draws nothing: the first class's. let mut ds = rustgis_data::Dataset::new( "kinds", rustgis_data::GeometryType::Polyline, vec![rustgis_data::Field::new("k", rustgis_data::FieldType::Text)], ); ds.push( Some(rustgis_data::Geometry::Line(vec![vec![rustgis_data::Coord::new(0.0, 0.0), rustgis_data::Coord::new(1.0, 1.0)]])), vec![rustgis_data::Value::Text("a".into())], ); let kinds = s.map_mut().add_dataset(ds); s.run("layer.symbology", &json!({"layer": kinds, "type": "unique", "expression": "k || 'x'"})).unwrap(); let l = s.map_mut().layer_mut(kinds).unwrap(); let rustgis_map::Renderer::Unique { other, classes, .. } = &mut l.renderer else { panic!() }; (other.fill, other.stroke) = (rustgis_map::Rgba::rgb(1, 2, 3), rustgis_map::Rgba::new(0, 0, 0, 0)); let first = classes[0].1.stroke.hex(); s.run("share.webStyle", &json!({"layer": kinds, "path": path.display().to_string()})).unwrap(); let style: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); let line = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "line").unwrap(); assert_eq!(line["paint"]["line-color"], json!(first)); std::fs::remove_dir_all(&dir).ok(); // Deleting a field it reads: a single symbol, as for a renderer on that field, and said. let mark = s.notice_mark(); let rustgis_map::Renderer::Unique { classes, .. } = &s.map().layer_by_name("Cities").unwrap().renderer else { panic!() }; let first_fill = classes[0].1.fill; s.run("edit.deleteField", &json!({"layer": "Cities", "field": "pop_max"})).unwrap(); let rustgis_map::Renderer::Single(drawn) = &s.map().layer_by_name("Cities").unwrap().renderer else { panic!() }; assert_eq!(drawn.fill, first_fill, "drawn with the first class's symbol"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("drawn with a single symbol")), "{:?}", s.notices_since(mark)); // A field nothing draws by goes without a word about the symbols. let mark = s.notice_mark(); s.run("edit.deleteField", &json!({"layer": "Cities", "field": "name"})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("single symbol")), "{:?}", s.notices_since(mark)); } #[test] fn qgis_measures_in_project_units_are_left_out() { // QGIS's $area is in the project's units, which aren't read: left out, noted, not read as m². let mut s = world(); let qml = |filter: &str| { format!( r#" "# ) }; let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": qml("$area > 2")})).unwrap(); let said: Vec<&str> = r["notes"].as_array().unwrap().iter().filter_map(|n| n["text"].as_str()).collect(); assert!(said.iter().any(|n| n.contains("the rule \"Big\"") && n.contains("project's units")), "{said:?}"); // Named only in quotes, it is text: the rule reads. let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": qml("name <> '$area'")})).unwrap(); assert!(!r["notes"].to_string().contains("project's units"), "{r}"); } /// A QGIS diagram reaches the layer through `style.importQgis`; categories on fields the layer /// hasn't are left out, and a diagram left with none removed, each noted with the fields. Undo /// takes it back. #[test] fn qgis_diagrams_import_on_the_fields_the_layer_has() { let mut s = world(); let qml = |attrs: &str| { format!( r##"{attrs}"## ) }; let notes = |r: &serde_json::Value| r["notes"].as_array().unwrap().iter().map(|n| n["text"].as_str().unwrap().to_string()).collect::>(); let both = r##""##; let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": qml(both)})).unwrap(); assert!(notes(&r).is_empty(), "{:?}", notes(&r)); let fields = |s: &Session| { s.map() .layer_by_name("Countries") .unwrap() .diagram .as_ref() .map(|d| d.categories.iter().map(|c| c.field.clone()).collect::>()) }; assert_eq!(fields(&s), Some(vec!["POP_EST".to_string(), "gdp_md".to_string()])); // One undo step takes it back. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(fields(&s), None); // A category on a field the layer hasn't is left out. let r = s .run( "style.importQgis", &json!({"layer": "Countries", "text": qml(&format!(r##"{both}"##))}), ) .unwrap(); assert!(notes(&r).iter().any(|n| n == "its diagram categories on fields the layer hasn't (nosuch)"), "{:?}", notes(&r)); assert_eq!(fields(&s), Some(vec!["POP_EST".to_string(), "gdp_md".to_string()])); // None left: no diagram, noted. let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": qml(r##""##)})).unwrap(); assert!(notes(&r).iter().any(|n| n == "its diagrams, whose fields the layer hasn't (nosuch)"), "{:?}", notes(&r)); assert_eq!(fields(&s), None); // Sized between two values, a pie losing a category is sized by the rest: noted. One of a // fixed size says nothing of its size. let scaled = |attrs: &str| { format!( r##"{attrs}"## ) }; let sized = "its diagrams sized by the sum of the categories left, without those on fields the layer hasn't"; let pair = r##""##; let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": scaled(pair)})).unwrap(); assert!(notes(&r).iter().any(|n| n == sized), "{:?}", notes(&r)); assert!(!notes(&r).iter().any(|n| n.contains("not by")), "the sum QGIS sizes by is the categories': {:?}", notes(&r)); let r = s.run("style.importQgis", &json!({"layer": "Countries", "text": qml(pair)})).unwrap(); assert!(!notes(&r).iter().any(|n| n == sized), "{:?}", notes(&r)); let r = s .run("style.importQgis", &json!({"layer": "Countries", "text": scaled(r##""##)})) .unwrap(); assert!(!notes(&r).iter().any(|n| n == sized), "nothing dropped: {:?}", notes(&r)); // A graphics layer takes none: noted as left out. s.run("graphics.newLayer", &json!({"name": "Sketch"})).unwrap(); let r = s.run("style.importQgis", &json!({"layer": "Sketch", "text": qml(both)})).unwrap(); assert!(notes(&r).iter().any(|n| n == "its diagrams (a graphics layer draws none)"), "{:?}", notes(&r)); assert!(s.map().layer_by_name("Sketch").unwrap().diagram.is_none()); } /// A QGIS categorized renderer by an expression classes by it where the expression reads on the /// layer; where it doesn't, every feature draws with the first category's symbol, noted, as before. #[test] fn qgis_categories_by_an_expression_class_by_it_where_it_reads() { let mut s = world(); let qml = |attr: &str, cats: &str| { format!( r#"{cats} "# ) }; let notes = |r: &serde_json::Value| r["notes"].as_array().unwrap().iter().map(|n| n["text"].as_str().unwrap().to_string()).collect::>(); let cats = r#""#; let big = "CASE WHEN "pop_max" > 5000000 THEN 'Big' END"; let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(big, cats)})).unwrap(); let said = notes(&r); assert!(!said.iter().any(|n| n.contains("expression")), "{said:?}"); let l = s.map().layer_by_name("Cities").unwrap(); assert_eq!(l.renderer.expression(), Some("CASE WHEN \"pop_max\" > 5000000 THEN 'Big' END")); let c = l.renderer.classer(&l.dataset, None); let pop = l.dataset.field_index("pop_max").unwrap(); for row in 0..l.dataset.features.len() { let over = l.dataset.value(row, pop).as_f64().is_some_and(|v| v > 5_000_000.0); assert_eq!(l.renderer.unique_class(&l.dataset, row, &c).is_some(), over, "row {row}"); } // Naming a field the layer hasn't: the first category's symbol for every feature, noted // with the expression as QGIS wrote it. let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("upper("nosuch")", cats)})).unwrap(); let said = notes(&r); assert!( said.iter() .any(|n| n.contains("upper(\"nosuch\")") && n.contains("doesn't fit the layer") && n.contains("first category's symbol")), "{said:?}" ); let fill = |s: &Session| match &s.map().layer_by_name("Cities").unwrap().renderer { rustgis_map::Renderer::Single(sym) => sym.fill.0, other => panic!("{other:?}"), }; assert_eq!(fill(&s), [255, 0, 0, 255]); // The first category is the first listed, "all other values" too. let rest_first = r#""#; s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("upper("nosuch")", rest_first)})).unwrap(); assert_eq!(fill(&s), [0, 0, 255, 255]); // Undone, the style before. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(fill(&s), [255, 0, 0, 255]); // A switched-off first category is passed over; with no category to draw, said so. let off_first = r#""#; s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("upper("nosuch")", off_first)})).unwrap(); assert_eq!(fill(&s), [255, 0, 0, 255]); let none = r#""#; let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("upper("nosuch")", none)})).unwrap(); assert!(notes(&r).iter().any(|n| n.contains("every feature drawn as the rest, which draws nothing")), "{:?}", notes(&r)); s.run("edit.undo", &Value::Null).unwrap(); // A field's name with a space, as QGIS writes it (unquoted): classed by that field. // (Add Field takes no space; a file's field can have one.) s.run("edit.addField", &json!({"layer": "Cities", "name": "size_class", "type": "text"})).unwrap(); s.run( "edit.calculateField", &json!({"layer": "Cities", "field": "size_class", "expression": "CASE WHEN pop_max > 5000000 THEN 'Big' END"}), ) .unwrap(); let id = s.map().layer_by_name("Cities").unwrap().id; if let Some(f) = s.map_mut().layer_mut(id).and_then(|l| l.dataset.fields.iter_mut().find(|f| f.name == "size_class")) { f.name = "Size Class".into(); } let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("size class", cats)})).unwrap(); assert!(notes(&r).is_empty(), "{:?}", notes(&r)); let l = s.map().layer_by_name("Cities").unwrap(); assert!( matches!(&l.renderer, rustgis_map::Renderer::Unique { field, expression: None, .. } if field == "Size Class"), "{:?}", l.renderer ); // Each feature over five million is "Big", every other the rest. let picks = |l: &rustgis_map::Layer| { let c = l.renderer.classer(&l.dataset, None); let Some(pop) = l.dataset.field_index("pop_max") else { panic!("pop_max") }; let Some(classes) = (match &l.renderer { rustgis_map::Renderer::Unique { classes, .. } => Some(classes.clone()), _ => None, }) else { panic!("{:?}", l.renderer) }; (0..l.dataset.features.len()) .map(|row| { let over = l.dataset.value(row, pop).as_f64().is_some_and(|v| v > 5_000_000.0); (over, l.renderer.unique_class(&l.dataset, row, &c).and_then(|i| classes.get(i)).map(|k| k.0.clone())) }) .collect::>() }; let rows = picks(l); assert!(rows.iter().any(|r| r.0)); for (row, (over, class)) in rows.iter().enumerate() { assert_eq!(class.as_deref(), over.then_some("Big"), "row {row}"); } // Boolean categories (as QGIS writes them) by such a field: matching its text "true" and // "false" as they are, a boolean field's values as RustGIS prints them. let bools = r#""#; // (A bare name is a field to the importer itself; one with a space is matched here.) for (ty, name) in [("text", "Is Big"), ("boolean", "Big Flag"), ("boolean", "big_bool")] { let tmp = name.replace(' ', "_"); s.run("edit.addField", &json!({"layer": "Cities", "name": tmp, "type": ty})).unwrap(); let e = if ty == "text" { "CASE WHEN pop_max > 5000000 THEN 'true' ELSE 'false' END" } else { "pop_max > 5000000" }; s.run("edit.calculateField", &json!({"layer": "Cities", "field": tmp, "expression": e})).unwrap(); if let Some(f) = s.map_mut().layer_mut(id).and_then(|l| l.dataset.fields.iter_mut().find(|f| f.name == tmp)) { f.name = name.into(); } let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(name, bools)})).unwrap(); assert!(notes(&r).is_empty(), "{:?}", notes(&r)); let rows = picks(s.map().layer_by_name("Cities").unwrap()); let (yes, no) = if ty == "text" { ("true", "false") } else { ("True", "False") }; for (row, (over, class)) in rows.iter().enumerate() { assert_eq!(class.as_deref(), Some(if *over { yes } else { no }), "{ty} {name} row {row}"); } } // A text category "True" beside boolean ones by a text field: each keeps its own value, the // boolean ones matching the field's "true" and "false", the text one only "True". let mixed = format!(r#"{bools}"#); let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml("Is Big", &mixed)})).unwrap(); assert!(notes(&r).is_empty(), "{:?}", notes(&r)); let l = s.map().layer_by_name("Cities").unwrap(); let rustgis_map::Renderer::Unique { classes, class_labels, .. } = &l.renderer else { panic!("{:?}", l.renderer) }; assert_eq!(classes.iter().map(|c| c.0.as_str()).collect::>(), ["True", "true", "false"], "{:?}", notes(&r)); assert_eq!(class_labels.get("True").map(String::as_str), Some("Text")); for (row, (over, class)) in picks(l).iter().enumerate() { assert_eq!(class.as_deref(), Some(if *over { "true" } else { "false" }), "mixed row {row}"); } // By an expression, boolean categories match its value as RustGIS prints it. let by_expr = ""pop_max" > 5000000"; let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(by_expr, bools)})).unwrap(); assert!(notes(&r).is_empty(), "{:?}", notes(&r)); for (row, (over, class)) in picks(s.map().layer_by_name("Cities").unwrap()).iter().enumerate() { assert_eq!(class.as_deref(), Some(if *over { "True" } else { "False" }), "expression row {row}"); } // ... unless another category has that value: kept as written, said once with a count. let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(by_expr, &mixed)})).unwrap(); let said = notes(&r); assert!(said.iter().any(|n| n.contains("1 boolean category kept as written (the first: true), as another category")), "{said:?}"); let l = s.map().layer_by_name("Cities").unwrap(); let rustgis_map::Renderer::Unique { classes, .. } = &l.renderer else { panic!("{:?}", l.renderer) }; assert_eq!(classes.iter().map(|c| c.0.as_str()).collect::>(), ["True", "true", "False"]); // ... or when two categories list the value: drawn as the rest, said. let twice = r#""#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(by_expr, twice)})).unwrap()); assert!(said.iter().any(|n| n.contains("its value listed by more than one category: its features drawn as the rest")), "{said:?}"); // The layer's own classes by an expression are left alone by a style that sets no renderer, // even one an import couldn't take (it measures: QGIS's measures are in the project's units). s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "expression": "CASE WHEN $area >= 0 THEN 'Big' END"})) .unwrap(); assert!(s.map().layer_by_name("Countries").unwrap().renderer.expression().is_some_and(|e| e.contains("$area"))); let before = s.map().layer_by_name("Countries").unwrap().renderer.clone(); let labels_only = r#""#; s.run("style.importQgis", &json!({"layer": "Countries", "text": labels_only})).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer, before); } #[test] fn qgis_rules_class_features_and_say_which_several_rules_pick() { let mut s = world(); let qml = |rules: &str| { format!( r#"{rules} "# ) }; let notes = |r: &serde_json::Value| r["notes"].as_array().unwrap().iter().map(|n| n["text"].as_str().unwrap().to_string()).collect::>(); // Every city of more than a million picks both rules, at the same scales. let big = s.map().layer_by_name("Cities").unwrap(); let both = (0..big.dataset.features.len()) .filter(|r| big.dataset.field_index("pop_max").and_then(|f| big.dataset.value(*r, f).as_f64()).is_some_and(|v| v > 5_000_000.0)) .count(); assert!(both > 0); let rules = r#" "#; let r = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(rules)})).unwrap(); let said = notes(&r); assert!(said.iter().any(|n| n.contains(&format!("{both} features that more than one of its rules picks"))), "{said:?}"); let l = s.map().layer_by_name("Cities").unwrap(); assert!(l.renderer.expression().is_some_and(|e| e.contains("THEN 'Million'")), "{:?}", l.renderer); // At scales that never meet, nothing is said. let apart = r#" "#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(apart)})).unwrap()); assert!(!said.iter().any(|n| n.contains("more than one")), "{said:?}"); // Rules reading the map's scale can't be counted at no scale: said without a count. let scaled = r#" "#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(scaled)})).unwrap()); assert!(said.iter().any(|n| n.contains("features that more than one of its rules picks (if any)")), "{said:?}"); // The scale named only in quotes is text: counted. let quoted = r#" "#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(quoted)})).unwrap()); assert!(said.iter().any(|n| n.contains(&format!("{both} features that more than one of its rules picks"))), "{said:?}"); // Only the features its definition query keeps are counted. let cities = s.map().layer_by_name("Cities").unwrap(); let shown = (0..cities.dataset.features.len()) .filter(|r| { cities.dataset.field_index("pop_max").and_then(|f| cities.dataset.value(*r, f).as_f64()).is_some_and(|v| v > 10_000_000.0) }) .count(); assert!(shown > 0 && shown < both); s.run("layer.definitionQuery", &json!({"layer": "Cities", "where": "pop_max > 10000000"})).unwrap(); let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(rules)})).unwrap()); assert!(said.iter().any(|n| n.contains(&format!("{shown} features that more than one of its rules picks"))), "{said:?}"); s.run("layer.definitionQuery", &json!({"layer": "Cities", "where": ""})).unwrap(); // An else rule picks what its siblings don't: nothing two rules pick. let rest = r#""#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(rest)})).unwrap()); assert!(!said.iter().any(|n| n.contains("more than one")), "{said:?}"); // A rule whose filter names a field the layer hasn't: left out by its label, the others kept. let gone = r#""#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(gone)})).unwrap()); assert!(said.iter().any(|n| n.contains("the rule \"Gone\"") && n.contains("nosuch")), "{said:?}"); // Several that don't fit: one note, with how many and the first. let two = format!("{gone}"); let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(&two)})).unwrap()); assert_eq!(said.iter().filter(|n| n.contains("fit the layer")).count(), 1, "{said:?}"); assert!(said.iter().any(|n| n.contains("2 rules whose filters don't fit the layer, the first \"Gone\"")), "{said:?}"); s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(gone)})).unwrap(); let l = s.map().layer_by_name("Cities").unwrap(); let rustgis_map::Renderer::Unique { classes, expression: Some(e), .. } = &l.renderer else { panic!("{:?}", l.renderer) }; assert_eq!((classes.len(), classes[0].0.as_str()), (1, "Kept")); assert!(!e.contains("nosuch"), "{e}"); let styler = l.styler(Some(1e6)); let drawn = (0..l.dataset.features.len()).filter(|r| styler.symbol(*r).fill == rustgis_map::Rgba::rgb(0, 0, 255)).count(); assert!(drawn > 0, "the kept rule draws"); // None that reads: the first rule's symbol for every feature, as before rules were classed. let none = r#""#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": qml(none)})).unwrap()); assert!(said.iter().any(|n| n.contains("none of whose filters fit the layer")), "{said:?}"); let l = s.map().layer_by_name("Cities").unwrap(); assert!(matches!(l.renderer, rustgis_map::Renderer::Single(sym) if sym.fill == rustgis_map::Rgba::rgb(0, 0, 255)), "{:?}", l.renderer); // A layer's filter that is an expression now carries over. let filtered = r#" "#; let said = notes(&s.run("style.importQgis", &json!({"layer": "Cities", "text": filtered})).unwrap()); assert!(said.is_empty(), "{said:?}"); let l = s.map().layer_by_name("Cities").unwrap(); assert_eq!(l.labels.classes.first().and_then(|c| c.where_.as_deref()), Some("length(\"name\") <= 5")); } #[test] fn labels_are_set_by_expressions() { let mut s = world(); let r = s .run( "labels.by", &json!({"layer": "Cities", "text": "upper(name)", "size": "scale_linear(pop_max, 0, 2e7, 8, 20)", "color": "if(pop_max > 1e7, '#ff0000', NULL)", "angle": "30"}), ) .unwrap(); assert_eq!((r["text"].as_str(), r["angle"].as_str()), (Some("upper(name)"), Some("30"))); let l = s.map().layer_by_name("Cities").unwrap(); assert!(l.labels.enabled, "a text expression turns the labels on"); assert_eq!(l.labels.default.by.color.as_deref(), Some("if(pop_max > 1e7, '#ff0000', NULL)")); let depth = s.undo_depth(); for (bad, says) in [ (json!({"layer": "Cities", "text": "nosuch || 'x'"}), "text: "), (json!({"layer": "Cities", "size": "("}), "size: "), (json!({"layer": "Cities", "color": 3}), "color is an expression"), (json!({"layer": "Cities", "class": "Nope", "angle": "4"}), "no label class named"), (json!({"layer": "Cities"}), "give text, size, color or angle"), ] { let err = s.run("labels.by", &bad).unwrap_err(); assert!(err.contains(says), "{bad}: {err}"); } assert_eq!(s.undo_depth(), depth); // A class's own; a key left out is kept, null removes one, and undo brings it back. s.run("labels.class.add", &json!({"layer": "Cities", "name": "Big", "where": "pop_max > 5000000"})).unwrap(); s.run("labels.by", &json!({"layer": "Cities", "class": "Big", "text": "name || '!'"})).unwrap(); s.run("labels.by", &json!({"layer": "Cities", "angle": null, "text": ""})).unwrap(); let l = s.map().layer_by_name("Cities").unwrap(); assert_eq!(l.labels.classes[0].by.text.as_deref(), Some("name || '!'")); assert!(l.labels.default.by.text.is_none() && l.labels.default.by.angle.is_none() && l.labels.default.by.size.is_some()); s.undo(); assert_eq!(s.map().layer_by_name("Cities").unwrap().labels.default.by.angle.as_deref(), Some("30")); // A style shared to the web says its label expressions are left out. let dir = std::env::temp_dir().join(format!("rustgis-labels-by-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("share.webStyle", &json!({"layer": "Cities", "path": dir.join("cities.json").display().to_string()})).unwrap(); assert!(s.notices.iter().any(|n| n.text.contains("label expressions aren't in it")), "{:?}", s.notices.last()); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); // Converted, each label keeps its text, size and colour: the annotation draws by its fields. s.run("labels.class.clear", &json!({"layer": "Cities"})).unwrap(); s.run("labels.by", &json!({"layer": "Cities", "text": "upper(name)", "angle": null})).unwrap(); s.run("labels.convert", &json!({"layer": "Cities"})).unwrap(); let a = s.map().layer_by_name("Cities Annotation").unwrap(); let names: Vec<&str> = a.dataset.fields.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["TEXT", "SIZE", "COLOR"]); let by = &a.labels.default.by; assert_eq!( (by.text.as_deref(), by.size.as_deref(), by.color.as_deref(), by.angle.as_deref()), (None, Some("\"SIZE\""), Some("\"COLOR\""), None) ); let fi = |n: &str| a.dataset.field_index(n).unwrap(); let paris = (0..a.dataset.features.len()).find(|r| a.dataset.value(*r, fi("TEXT")).to_string() == "PARIS").expect("upper-case text"); assert!(a.dataset.value(paris, fi("SIZE")).as_f64().is_some_and(|v| v > 8.0)); let red = (0..a.dataset.features.len()).filter(|r| a.dataset.value(*r, fi("COLOR")).to_string() == "255,0,0,255").count(); assert!(red > 0 && red < a.dataset.features.len(), "only the big cities' red, the rest the class's"); assert!(!s.notices.iter().any(|n| n.text.contains("cut to")), "short texts aren't cut"); } /// What label expressions lose is said, and what's reported or kept follows what the map draws: /// a text cut by Convert To Annotation, the Web Style's notices, Project Info's labels, and the /// angle a converted label keeps, decided for each feature. #[test] fn label_expressions_say_what_they_lose() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; let mut s = world(); let cities = s.map().layer_by_name("Cities").unwrap().id; // A text past its most characters is cut, said once with a count. let long = format!("name || '{}'", "x".repeat(rustgis_map::label_by::MAX_LABEL_TEXT)); s.run("labels.by", &json!({"layer": "Cities", "text": long})).unwrap(); let n = s.run("labels.convert", &json!({"layer": "Cities"})).unwrap()["annotations"].as_u64().unwrap(); let said = format!("Cities Annotation: {n} label texts cut to {} characters", rustgis_map::label_by::MAX_LABEL_TEXT); assert!(s.notices.iter().any(|x| x.text == said), "{:?}", s.notices.last()); s.undo(); // Project Info names the text expression the labels draw by, and the field once it doesn't read. s.map_mut().layer_mut(cities).unwrap().labels.default.field = "name".into(); s.run("labels.by", &json!({"layer": "Cities", "text": "upper(name)"})).unwrap(); let labels = |s: &mut Session| { let info = s.run("info.project", &Value::Null).unwrap(); info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Cities").unwrap()["labels"].clone() }; assert_eq!(labels(&mut s), json!("upper(name)")); s.map_mut().layer_mut(cities).unwrap().labels.default.by.text = Some("upper(gone)".into()); assert_eq!(labels(&mut s), json!("name")); // Deleting the field Default labels by keeps the labels while its text expression reads, and // starts Default again (labels off) once it doesn't. s.run("edit.addField", &json!({"layer": "Cities", "name": "spare", "type": "text"})).unwrap(); for (text, on) in [("'#' || pop_max", true), ("upper(spare)", false)] { let l = s.map_mut().layer_mut(cities).unwrap(); (l.labels.enabled, l.labels.default.field, l.labels.default.by.text) = (true, "spare".into(), Some(text.into())); s.run("edit.deleteField", &json!({"layer": "Cities", "field": "spare"})).unwrap(); let l = s.map().layer(cities).unwrap(); assert_eq!(l.labels.enabled, on, "{text}"); assert_eq!(l.labels.default.by.text.is_some(), on, "{text}"); assert!(l.labels.default.field != "spare"); s.undo(); } s.map_mut().layer_mut(cities).unwrap().labels.default.by.text = Some("upper(name)".into()); // Web Style: a Default class labelling by a text expression alone says only that its style has // none, even with other classes; one with a size but no text or field says nothing of it. let dir = std::env::temp_dir().join(format!("rustgis-labels-lose-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let share = |s: &mut Session| { let before = s.notices.len(); s.run("share.webStyle", &json!({"layer": "Cities", "path": dir.join("c.json").display().to_string()})).unwrap(); s.notices[before..].iter().map(|n| n.text.clone()).collect::>() }; let l = s.map_mut().layer_mut(cities).unwrap(); l.labels.default.field.clear(); l.labels.default.by.text = Some("upper(name)".into()); s.run("labels.class.add", &json!({"layer": "Cities", "name": "Big", "where": "pop_max > 5000000"})).unwrap(); // Project Info lists each class's text expression with its field. s.run("labels.by", &json!({"layer": "Cities", "class": "Big", "text": "name || '!'"})).unwrap(); let info = s.run("info.project", &Value::Null).unwrap(); let classes = &info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Cities").unwrap()["labelClasses"]; assert_eq!(classes[0]["text"], json!("name || '!'"), "{classes}"); let said = share(&mut s); assert!(said.iter().any(|t| t.contains("has no labels: its Default class's text is an expression")), "{said:?}"); assert!(!said.iter().any(|t| t.contains("labels every feature")), "{said:?}"); let l = s.map_mut().layer_mut(cities).unwrap(); (l.labels.default.by.text, l.labels.default.by.size) = (None, Some("8".into())); let said = share(&mut s); assert!(said.iter().any(|t| t.contains("has no labels: its Default class has no label field")), "{said:?}"); assert!(!said.iter().any(|t| t.contains("expression") || t.contains("labels every feature")), "{said:?}"); std::fs::remove_dir_all(&dir).ok(); // A converted label keeps its angle on a point, and none on a line it is placed along, in a // layer of both; a layer of lines alone gets no ANGLE field. let line = Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(5.0, 0.0)]]); let point = Geometry::Point(Coord::new(1.0, 1.0)); for (geoms, angles) in [(vec![line.clone(), point], Some(vec![Attr::Null, Attr::Float(30.0)])), (vec![line], None)] { let mut ds = Dataset::new("ways", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); for g in geoms { ds.push(Some(g), vec![Attr::Text("w".into())]); } let id = s.map_mut().add_dataset(ds); let l = s.map_mut().layer_mut(id).unwrap(); (l.labels.enabled, l.labels.default.field, l.labels.default.placement) = (true, "name".into(), rustgis_map::LabelPlacement::Parallel); l.labels.default.by.angle = Some("30".into()); let a = s.run("labels.convert", &json!({"layer": id})).unwrap()["layer"].as_u64().unwrap() as u32; let a = &s.map().layer(a).unwrap().dataset; let got = a.field_index("ANGLE").map(|fi| (0..a.features.len()).map(|r| a.value(r, fi).clone()).collect::>()); assert_eq!(got, angles); } } #[test] fn a_qgis_styles_data_defined_settings_are_kept_where_they_read() { let mut s = world(); // Size by a field Cities has; fill by a QGIS function RustGIS lacks; outline by a field it hasn't. let dd = |name: &str, expr: &str| { format!( r#""# ) }; let qml = format!( r#" "#, dd("size", ""pop_max" / 1e6"), dd("fillColor", "set_color_part(@symbol_color, 'value', 40)"), dd("outlineColor", ""nosuch""), ); let out = s.run("style.importQgis", &json!({"layer": "Cities", "text": qml})).unwrap(); let by = &s.map().layer_by_name("Cities").unwrap().symbol_by; assert_eq!((by.size.as_deref(), &by.fill, &by.stroke), (Some(r#""pop_max" / 1e6"#), &None, &None), "{by:?}"); let notes = serde_json::to_string(&out["notes"]).unwrap(); assert!( notes.contains("data-defined symbol fill colour `set_color_part") && notes.contains("outline colour `\\\"nosuch\\\"`"), "{notes}" ); } #[test] fn a_stroke_is_dashed_by_command() { let mut s = world(); let stroke = |dash: Value| json!({"layer": "Rivers", "strokes": [{"color": "#000000", "width": 2, "dash": dash}]}); s.run("layer.lineStrokes", &stroke(json!([4, 2.5]))).unwrap(); let dash = |s: &Session| s.map().layer_by_name("Rivers").unwrap().line_strokes[0].dash.map(|d| d.steps().to_vec()); assert_eq!(dash(&s), Some(vec![4.0, 2.5])); let depth = s.undo_depth(); for bad in [json!([]), json!([0, 0]), json!([-1, 2]), json!([1, "2"]), json!("4 2"), json!([1, 1, 1, 1, 1, 1, 1, 1, 1]), json!([1e9])] { assert!(s.run("layer.lineStrokes", &stroke(bad.clone())).is_err(), "{bad}"); } assert_eq!((s.undo_depth(), dash(&s)), (depth, Some(vec![4.0, 2.5])), "a refused pattern changes nothing"); s.run("layer.lineStrokes", &stroke(Value::Null)).unwrap(); assert_eq!(dash(&s), None, "null: solid"); } #[test] fn a_class_stacks_strokes_of_its_own() { use rustgis_map::symbology::ClassRef; use rustgis_map::{LineStroke, Rgba}; let mut s = world(); s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "scalerank"})).unwrap(); // The layer's one stroke, a casing and core for rivers of rank 1, a thin line for // ; rank 2 keeps the layer's. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"offset": 3}]})).unwrap(); let depth = s.undo_depth(); let pair = json!([{"color": "#000000", "width": 6}, {"color": "#FF8C00", "width": 3}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": pair})).unwrap(); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "other": true, "strokes": [{"width": 0.5}]})).unwrap(); assert_eq!(s.undo_depth(), depth + 2); let casing = vec![ LineStroke { color: Some(Rgba::rgb(0, 0, 0)), width: Some(6.0), ..Default::default() }, LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), width: Some(3.0), ..Default::default() }, ]; let layer_own = vec![LineStroke { offset: 3.0, ..Default::default() }]; let check = |s: &Session| { let l = s.map().layer_by_name("Rivers").unwrap(); let fi = l.dataset.field_index("scalerank").unwrap(); assert!(l.has_strokes()); for row in 0..l.dataset.features.len() { let want = if l.dataset.value(row, fi).to_string() == "1" { &casing } else { &layer_own }; assert_eq!(l.strokes(row), want.as_slice(), "row {row}"); // Classed once, the same as asked apart. assert_eq!(l.style(row), (l.symbol(row), want.as_slice()), "row {row}"); } assert_eq!(l.renderer.class_strokes(&ClassRef::Other).unwrap().len(), 1); }; check(&s); // A number names the class as the data prints it. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": 1, "strokes": pair})).unwrap(); check(&s); // Kept in the project file, and through applying the same symbology again. let dir = std::env::temp_dir().join(format!("rustgis-class-strokes-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("strokes.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); check(&t); let png = dir.join("map.png"); t.run("share.exportMap", &json!({"path": png.display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "scalerank", "ramp": "Ember"})).unwrap(); check(&s); // Junk, a class that isn't there, or a class on the wrong renderer: refused, nothing changes. let depth = s.undo_depth(); for bad in [ json!({"layer": "Rivers", "value": "7", "strokes": pair}), json!({"layer": "Rivers", "index": 0, "strokes": pair}), json!({"layer": "Rivers", "value": "1", "index": 0, "strokes": pair}), json!({"layer": "Rivers", "value": [1], "strokes": pair}), json!({"layer": "Rivers", "value": "1", "strokes": vec![json!({}); 17]}), json!({"layer": "Rivers", "value": "1", "strokes": [{"cap": "pointy"}]}), json!({"layer": "Cities", "value": "1", "strokes": []}), ] { assert!(s.run("layer.lineStrokes", &bad).is_err(), "{bad}"); } assert_eq!(s.undo_depth(), depth); // [] gives the class back to the layer's strokes; undo brings its own back. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": []})).unwrap(); assert!(s.map().layer_by_name("Rivers").unwrap().strokes(0).iter().all(|st| st.offset == 3.0)); s.run("edit.undo", &Value::Null).unwrap(); check(&s); // Another field starts fresh. s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "name"})).unwrap(); assert!(!s.map().layer_by_name("Rivers").unwrap().renderer.has_class_strokes()); // Graduated Colors classes go by index; a single symbol has no classes. s.run("layer.symbology", &json!({"layer": "Rivers", "type": "graduated", "field": "scalerank", "classes": 2})).unwrap(); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 0, "strokes": pair})).unwrap(); assert_eq!(s.map().layer_by_name("Rivers").unwrap().renderer.class_strokes(&ClassRef::Index(0)).unwrap(), casing.as_slice()); assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 9, "strokes": pair})).is_err()); assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": pair})).is_err()); s.run("layer.symbology", &json!({"layer": "Rivers", "type": "single"})).unwrap(); assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 0, "strokes": pair})).is_err()); } #[test] fn strokes_draw_in_levels() { let mut s = world(); let depth = s.undo_depth(); let strokes = json!([{"color": "#000000", "width": 6, "level": 2}, {"color": "#FFFFFF", "width": 2}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); s.run("layer.strokeLevels", &json!({"layer": "Rivers", "on": true})).unwrap(); assert_eq!(s.undo_depth(), depth + 2); let check = |s: &Session| { let l = s.map().layer_by_name("Rivers").unwrap(); assert!(l.stroke_levels); assert_eq!(l.line_strokes[0].level, Some(2)); // Without a level of its own, a stroke draws at its place in the stack. assert_eq!((l.line_strokes[0].level_at(0), l.line_strokes[1].level_at(1)), (2, 1)); }; check(&s); let dir = std::env::temp_dir().join(format!("rustgis-stroke-levels-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("levels.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); check(&t); t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); let depth = s.undo_depth(); for (id, bad) in [ ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": 256}]})), ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": -1}]})), ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": 1.5}]})), ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": "top"}]})), ("layer.strokeLevels", json!({"layer": "Rivers"})), ("layer.strokeLevels", json!({"layer": "Cities", "on": true})), ] { assert!(s.run(id, &bad).is_err(), "{id} {bad}"); } assert_eq!(s.undo_depth(), depth); s.run("layer.strokeLevels", &json!({"layer": "Rivers", "on": false})).unwrap(); assert!(!s.map().layer_by_name("Rivers").unwrap().stroke_levels); s.run("edit.undo", &Value::Null).unwrap(); check(&s); } #[test] fn a_reference_scale_is_set_kept_and_undone() { let mut s = world(); let depth = s.undo_depth(); s.run("layer.referenceScale", &json!({"layer": "Rivers", "scale": 25000})).unwrap(); // "current" takes the map's scale, rounded. let now = s.map().scale().round(); s.run("layer.referenceScale", &json!({"layer": "Cities", "scale": "current"})).unwrap(); assert_eq!(s.undo_depth(), depth + 2); let check = |s: &Session| { let at = |n: &str| s.map().layer_by_name(n).unwrap().reference_scale; assert_eq!((at("Rivers"), at("Cities"), at("Lakes")), (Some(25000.0), Some(now), None)); }; check(&s); let dir = std::env::temp_dir().join(format!("rustgis-reference-scale-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("reference.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); check(&t); t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); let depth = s.undo_depth(); for bad in [ json!({"layer": "Rivers", "scale": 0}), json!({"layer": "Rivers", "scale": -25000}), json!({"layer": "Rivers", "scale": 1e12}), json!({"layer": "Rivers", "scale": "big"}), json!({"layer": "Rivers", "scale": [25000]}), json!({"layer": "Nowhere", "scale": 25000}), ] { assert!(s.run("layer.referenceScale", &bad).is_err(), "{bad}"); } assert_eq!(s.undo_depth(), depth); s.run("layer.referenceScale", &json!({"layer": "Rivers", "scale": null})).unwrap(); assert_eq!(s.map().layer_by_name("Rivers").unwrap().reference_scale, None); s.run("edit.undo", &Value::Null).unwrap(); check(&s); } #[test] fn rivers_are_labelled_along_their_lines_in_italics() { let mut s = world(); s.run("labels.field", &json!({"layer": "Rivers", "field": "name"})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved"})).unwrap(); s.run("labels.style", &json!({"layer": "Rivers", "italic": true})).unwrap(); let check = |s: &Session| { let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; assert_eq!((c.placement, c.italic), (rustgis_map::LabelPlacement::Curved, true)); }; check(&s); let dir = std::env::temp_dir().join(format!("rustgis-line-labels-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("labels.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); check(&t); t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); std::fs::remove_dir_all(&dir).ok(); s.run("labels.placement", &json!({"layer": "Rivers", "position": "parallel"})).unwrap(); assert_eq!(s.map().layer_by_name("Rivers").unwrap().labels.default.placement, rustgis_map::LabelPlacement::Parallel); // Not slanted unless asked: a project from before reads as upright. s.run("labels.style", &json!({"layer": "Rivers", "italic": false})).unwrap(); let text = serde_json::to_string(&s.map().layer_by_name("Rivers").unwrap().labels).unwrap(); assert!(!text.contains("italic"), "{text}"); } /// A true/false parameter reads true, false, yes, no, on, off, 1 and 0 in any case; other text is /// read as false and another number ignored, each named in one notice per command (#1600). #[test] fn unread_boolean_parameter_text_is_said() { let mut s = world(); const SAID: &str = "Not read as true or false:"; let unread = |s: &Session| s.notices.iter().filter(|n| n.text.starts_with(SAID)).count(); let style = |s: &Session| { let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; (c.bold, c.italic) }; for (given, expect) in [("True", true), (" YES ", true), ("On", true), ("1", true), ("False", false), ("no", false), ("OFF", false), ("0", false)] { s.run("labels.style", &json!({"layer": "Rivers", "italic": given, "bold": given})).unwrap(); assert_eq!(style(&s), (expect, expect), "{given}"); } s.run("labels.style", &json!({"layer": "Rivers", "italic": 1, "bold": true})).unwrap(); assert_eq!(style(&s), (true, true)); s.run("labels.style", &json!({"layer": "Rivers", "italic": 0, "bold": 0.0})).unwrap(); assert_eq!(style(&s), (false, false)); s.run("labels.style", &json!({"layer": "Rivers", "italic": 1, "bold": true})).unwrap(); assert_eq!(unread(&s), 0, "clean input gives no notice: {:?}", s.notices.last()); s.run("labels.style", &json!({"layer": "Rivers", "italic": "auto", "bold": "ture"})).unwrap(); assert_eq!(style(&s), (false, false)); assert_eq!(unread(&s), 1, "{:?}", s.notices.last()); let said = s.notices.iter().find(|n| n.text.starts_with(SAID)).map(|n| n.text.clone()).unwrap(); assert!(said.contains("italic = \"auto\" (read as false)") && said.contains("bold = \"ture\" (read as false)"), "{said}"); // A number other than 1 or 0 leaves the setting as it was, and says so. s.run("labels.style", &json!({"layer": "Rivers", "italic": true})).unwrap(); s.run("labels.style", &json!({"layer": "Rivers", "italic": 5})).unwrap(); assert_eq!(style(&s), (false, true)); assert_eq!(unread(&s), 2, "{:?}", s.notices.last()); assert!(s.notices.iter().any(|n| n.text.contains("italic = 5 (ignored)")), "{:?}", s.notices.last()); // A long value is said by its length, not quoted in part; ten distinct values name eight // and count the rest once each, however often a command reads them. let long = "k".repeat(41); s.run("labels.style", &json!({"layer": "Rivers", "italic": long})).unwrap(); let said = s.notices.iter().rev().find(|n| n.text.starts_with(SAID)).map(|n| n.text.clone()).unwrap(); assert!(said.contains("italic = a value of 41 characters (read as false)") && !said.contains("kkk"), "{said}"); let p: Value = (0..10).map(|i| (format!("k{i}"), json!(format!("v{i}")))).collect::>().into(); crate::cmd::clear_unread_bools(); for _ in 0..3 { for i in 0..10 { assert_eq!(crate::cmd::b(&p, &format!("k{i}")), Some(false)); } } let said = crate::cmd::take_unread_bools().unwrap(); assert!(said.contains("k7 = \"v7\"") && !said.contains("k8 =") && said.contains(", 2 more ("), "{said}"); // The next clean command says nothing more. let before = unread(&s); s.run("labels.style", &json!({"layer": "Rivers", "italic": true})).unwrap(); assert_eq!(unread(&s), before); } #[test] fn a_qgis_project_in_degrees_opens_in_equirectangular() { // QGIS draws a project in degrees as plate carrée, longitude and latitude as x and y. // A PROJ string goes where QGIS keeps it for a system without an authority code. let qgs = |crs: &str| { let tag = if crs.starts_with('+') { "proj4" } else { "authid" }; format!( r#"World<{tag}>{crs} 5451550"# ) }; let mut s = world(); for (crs, datum_note) in [ ("EPSG:4326", false), ("EPSG:4258", false), ("OGC:CRS84", false), ("EPSG:4314", true), // Saved without an authority code: its PROJ string. ("+proj=longlat +datum=WGS84 +no_defs", false), ("+proj=longlat +datum=WGS84 +pm=0.0 +no_defs", false), ("+proj=longlat +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +no_defs", true), ] { let out = s.run("project.importQgis", &json!({"text": qgs(crs)})).unwrap(); assert_eq!(s.map().projection.code(), "equirectangular", "{crs}"); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(!text.contains("Web Mercator"), "{crs}: {text}"); assert_eq!(text.contains("drawn in Equirectangular on WGS 84"), datum_note, "{crs}: {text}"); // The saved view, read in degrees. let c = s.map().to_lonlat(s.map().camera.center).unwrap(); assert!((c.x - 10.0).abs() < 0.5 && (c.y - 47.5).abs() < 0.5, "{crs}: {c:?}"); } // Longitudes from Paris (in grads): drawn the same way, the view left to the data. let out = s.run("project.importQgis", &json!({"text": qgs("EPSG:4807")})).unwrap(); assert_eq!(s.map().projection.code(), "equirectangular"); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(text.contains("on WGS 84") && text.contains("from Greenwich"), "{text}"); // A projected one keeps its own projection. s.run("project.importQgis", &json!({"text": qgs("EPSG:2056").replace("5", "2600000")})).unwrap(); assert_eq!(s.map().projection.code(), "EPSG:2056"); } #[test] fn a_qgis_project_with_a_geographic_proj_string_keeps_its_saved_view() { for (crs, datum_note, saved_view) in [ ("+proj=longlat +datum=WGS84", false, true), ("+proj=latlong +datum=WGS84", false, true), ("+proj=lonlat +datum=WGS84", false, true), ("+proj=latlon +datum=WGS84", false, true), (" +datum=WGS84 +proj=longlat +no_defs", false, true), ("+proj=longlat +datum=potsdam", true, true), ("+proj=longlat +datum=WGS84 +pm=paris", true, false), ] { let text = format!( r#"{crs} 5451550"# ); let mut s = world(); let out = s.run("project.importQgis", &json!({"text": text})).unwrap(); assert_eq!(s.map().projection.code(), "equirectangular", "{crs}"); let notes = serde_json::to_string(&out["notes"]).unwrap(); assert!(!notes.contains("Web Mercator"), "{crs}: {notes}"); assert_eq!(notes.contains("drawn in Equirectangular on WGS 84"), datum_note, "{crs}: {notes}"); let c = s.map().to_lonlat(s.map().camera.center).unwrap(); assert_eq!((c.x - 10.0).abs() < 0.5 && (c.y - 47.5).abs() < 0.5, saved_view, "{crs}: {c:?}"); } } #[test] fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() { let dir = std::env::temp_dir().join(format!("rustgis-qgis-{}", std::process::id())); std::fs::create_dir_all(dir.join("data")).unwrap(); let roads = r#"{"type":"FeatureCollection","features":[ {"type":"Feature","properties":{"class":"motorway","name":"A1","lanes":3},"geometry":{"type":"LineString","coordinates":[[8.70,47.49],[8.75,47.51]]}}, {"type":"Feature","properties":{"class":"track","name":"Feldweg","lanes":1},"geometry":{"type":"LineString","coordinates":[[8.71,47.50],[8.74,47.48]]}}]}"#; std::fs::write(dir.join("data/roads.geojson"), roads).unwrap(); std::fs::write(dir.join("data/towns.csv"), "name,lon,lat\nWinterthur,8.72,47.50\n").unwrap(); let qgs = r#" Winterthur EPSG:2056 001010 r./data/roads.geojson|subset="lanes" > 0Roadsogr tfile:./data/towns.csv?type=csv&xField=lon&yField=lat&crs=EPSG:4326Townsdelimitedtext dbdbname='gis' host=db password='secret' table="public"."parcels" (geom)Parcelspostgres "#; let path = dir.join("winterthur.qgs"); std::fs::write(&path, qgs).unwrap(); let mut s = world(); let maps = s.project.maps.len(); let depth = s.undo_depth(); let out = s.run("project.importQgis", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!((s.project.maps.len(), s.map().name.as_str()), (maps + 1, "Winterthur"), "a map of its own"); assert_eq!(s.undo_depth(), depth + 1, "one step"); assert_eq!(s.map().projection.code(), "EPSG:2056"); assert_eq!(s.map().basemap, rustgis_map::Basemap::Blank); let names: Vec<&str> = s.map().layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, ["Towns", "Roads"], "top first; the database table isn't added"); let roads = s.map().layer_by_name("Roads").unwrap(); assert!(!roads.visible, "unchecked in the layer tree"); assert_eq!(roads.definition_query.as_deref(), Some("\"lanes\" > 0")); assert!(roads.reference_scale.is_some()); let rustgis_map::Renderer::Unique { field, class_strokes, .. } = &roads.renderer else { panic!("{:?}", roads.renderer) }; assert_eq!((field.as_str(), class_strokes.get("motorway").map(Vec::len)), ("class", Some(2))); let classes: Vec<(&str, bool)> = roads.labels.classes.iter().map(|c| (c.name.as_str(), c.visible)).collect(); assert_eq!(classes, [("Big", true), ("Broken", false)], "a clause the layer can't read is switched off"); assert_eq!(roads.labels.classes[0].placement, rustgis_map::LabelPlacement::Parallel); let towns = s.map().layer_by_name("Towns").unwrap(); assert_eq!((towns.dataset.features.len(), towns.renderer.base_symbol().marker), (1, rustgis_map::Marker::Square)); // What wasn't carried over: the database table, with no password in the note. let notes = out["notes"].as_array().unwrap(); let text = serde_json::to_string(notes).unwrap(); assert!(text.contains("parcels") && !text.contains("secret"), "{text}"); assert!(text.contains("Broken"), "{text}"); // Drawn, and gone again with one undo. s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.maps.len(), maps); // A style file on a sample layer: Rivers drawn as a cased line. let qml = r#" 0.5"#; s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap(); let rivers = s.map().layer_by_name("Rivers").unwrap(); assert_eq!((rivers.line_strokes.len(), rivers.transparency), (2, 50.0)); // A raster's style on a feature layer says its colouring is left out. let qml = r#""#; let out = s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap(); assert!(serde_json::to_string(&out["notes"]).unwrap().contains("a raster's colouring"), "{out}"); assert!(s.run("style.importQgis", &json!({"layer": "Rivers", "text": " "#; let out = s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap(); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(text.contains("nofield") && text.contains("nolabelfield"), "{text}"); let rivers = s.map().layer_by_name("Rivers").unwrap(); assert!(rivers.labels.classes.iter().chain(std::iter::once(&rivers.labels.default)).all(|c| c.field != "nolabelfield" || !c.visible)); // A project's web layer keeps its visibility and opacity; its scale range is noted. let qgs = r#"Web xtype=xyz&url=https://tiles.example/%7Bz%7D/%7Bx%7D/%7By%7D.png&zmax=19&zmin=0Tileswms 13"#; let out = s.run("project.importQgis", &json!({"text": qgs})).unwrap(); let t = s.map().tiles.iter().find(|t| t.name == "Tiles").expect("the web layer is added"); assert!(!t.visible, "unchecked in the layer tree"); assert!((t.opacity - 0.25).abs() < 1e-6, "{}", t.opacity); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(text.contains("scale range"), "{text}"); // Web layers are overlays, so they stay in the tree's order, and take the layer's blending. let t = s.map().tiles.iter().find(|t| t.name == "Tiles").unwrap(); assert!(t.overlay); assert_eq!(t.blend, rustgis_map::effects::Blend::Multiply); // A layer of no provider (QGIS's annotation layer) is noted by its kind. let qgs = r#"NotesaNotes"#; let out = s.run("project.importQgis", &json!({"text": qgs})).unwrap(); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(text.contains("doesn't read QGIS's annotation layers"), "{text}"); // A project given as text with no folder: a relative path is noted, not looked for in the // working directory. let rel = r#"a./data/roads.geojsonRelogr"#; for p in [json!({"text": rel}), json!({"text": rel, "dir": " "})] { let out = s.run("project.importQgis", &p).unwrap(); assert!(out["layers"].as_array().unwrap().is_empty(), "{p}"); let text = serde_json::to_string(&out["notes"]).unwrap(); assert!(text.contains("pass dir"), "{text}"); } // XML past what's parsed (64 MiB) is refused before parsing. assert!(s.run("project.importQgis", &json!({"text": " ".repeat((64 << 20) + 1)})).is_err()); // A path relative to a drive's working directory is noted, with or without a folder. let drive = rel.replace("./data/roads.geojson", "C:roads.geojson"); let out = s.run("project.importQgis", &json!({"text": drive, "dir": dir.display().to_string()})).unwrap(); assert!(serde_json::to_string(&out["notes"]).unwrap().contains("drive"), "{out}"); // A web layer a service offers as features or a coverage comes in as a map layer, which takes // the QGIS layer's visibility, opacity, blending and scale range. let web = r#" wurl=https://wms.example/wms&layers=aWebwms 13"#; let mut report = rustgis_map::qgis::Report::default(); let project = rustgis_map::qgis::read_project(web, "", &mut report).unwrap(); let mut w = world(); let before = w.active_layer; w.active_layer = w.map().layers.iter().map(|l| l.id).find(|id| Some(*id) != before); crate::cmd::qgis::web_settings(&mut w, &json!({"name": "Web"}), before, &project.layers[0], &mut report); let l = w.map().layers.iter().find(|l| Some(l.id) == w.active_layer).unwrap(); assert!(!l.visible); assert_eq!((l.transparency, l.blend), (50.0, rustgis_map::effects::Blend::Multiply)); assert!(l.min_scale.is_some() && l.max_scale.is_some()); // Plugins can't run the import: a project names files and web addresses its layers come from. assert!(crate::cmd::plugins::user_only("project.importQgis")); std::fs::remove_dir_all(&dir).ok(); } #[test] fn project_save_and_open_roundtrip() { let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("atlas.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(t.map().layers.len(), 4); let png = dir.join("map.png"); t.run("share.exportMap", &json!({"path": png.display().to_string(), "width": 320, "height": 200})).unwrap(); assert!(std::fs::metadata(&png).unwrap().len() > 1000); std::fs::remove_dir_all(&dir).ok(); } /// RustGIS files are read only from regular files: a device that reports no size and never ends /// (`/dev/zero`) would exhaust memory, and a named pipe without a writer would hang the reader. #[cfg(unix)] #[test] fn item_files_refuse_devices_and_pipes() { let dir = std::env::temp_dir().join(format!("rustgis-item-pipe-{}", std::process::id())); std::fs::remove_dir_all(&dir).ok(); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); for (id, name) in [ ("style.add", "styles.rgisstyle"), ("report.open", "report.rgisreport"), ("project.open", "p.rgis"), ("project.open", "p.rgispkg"), ("project.importQgis", "q.qgz"), ("layout.import", "l.rgislayout"), ("dl.import", "m.rgisdlpk"), ("layer.addData", "l.rgislyr"), ] { let fifo = dir.join(name); assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); for path in [std::path::Path::new("/dev/zero"), fifo.as_path()] { if id == "layer.addData" && path == std::path::Path::new("/dev/zero") { continue; // Without its extension it isn't read as a layer file. } let error = s.run(id, &json!({"path": path})).unwrap_err(); assert!(error.contains("isn't a file"), "{id} {}: {error}", path.display()); } } std::fs::remove_dir_all(&dir).ok(); } /// Save As and packaging must not replace readable files with a project above the opening limit. #[test] fn oversized_project_save_preserves_file_and_live_state() { let dir = std::env::temp_dir().join(format!("rustgis-save-limit-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("existing.rgis"); let mut s = world(); s.run("project.saveAs", &json!({"path": path})).unwrap(); let original = std::fs::read(&path).unwrap(); let package = dir.join("existing.rgispkg"); s.run("project.package", &json!({"path": package})).unwrap(); let original_package = std::fs::read(&package).unwrap(); s.run("project.rename", &json!({"name": "Unsaved work"})).unwrap(); let source = dir.join("roads.geojson").display().to_string(); s.project.maps[0].layers[0].dataset.source = Some(source.clone()); // A single large value reaches the limit without a large data fixture on disk; the limit is // held at 1 MiB here, as it follows this computer's memory (#1615). let max = 1 << 20; s.project.thumbnail = Some("x".repeat(max as usize)); let (error, package_error) = crate::project::with_max_bytes(max, || { (s.run("project.saveAs", &json!({"path": path})).unwrap_err(), s.run("project.package", &json!({"path": package})).unwrap_err()) }); assert!(error.contains("too large to save (maximum 1 MiB"), "{error}"); assert!(package_error.contains("too large"), "{package_error}"); assert_eq!(std::fs::read(&path).unwrap(), original); assert_eq!(std::fs::read(&package).unwrap(), original_package); assert_eq!(s.project.name, "Unsaved work"); assert_eq!(s.project.maps[0].layers[0].dataset.source.as_deref(), Some(source.as_str())); assert_eq!(s.path.as_deref(), Some(path.as_path())); assert!(s.dirty(), "a failed save must not mark the project saved"); s.project.thumbnail = None; s.run("project.saveAs", &json!({"path": path})).unwrap(); assert!(!s.dirty()); assert!(Project::load(&path).is_ok()); std::fs::remove_dir_all(&dir).unwrap(); } /// The project limit follows the computer's memory (#1615): a project as large as the limit saves /// and opens again on the same computer, and one past it is refused before it is read, saying its /// size and the limit. #[test] fn a_project_within_the_limit_saves_and_opens_and_one_past_it_says_both_sizes() { let dir = std::env::temp_dir().join(format!("rustgis-project-limit-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("near.rgis"); let max: u64 = 2 << 20; let mut s = world(); let small = { s.run("project.saveAs", &json!({"path": path})).unwrap(); std::fs::metadata(&path).unwrap().len() }; // Just under the limit: written and read back whole, the same limit both ways. s.project.thumbnail = Some("x".repeat((max - small - 1024) as usize)); crate::project::with_max_bytes(max, || { s.run("project.saveAs", &json!({"path": path})).unwrap(); assert!(std::fs::metadata(&path).unwrap().len() <= max); assert_eq!(Project::load(&path).unwrap().thumbnail.map(|t| t.len()), s.project.thumbnail.as_ref().map(|t| t.len())); }); // Past it (as a project saved on a computer with more memory): refused, saying both sizes. let big = dir.join("big.rgis"); std::fs::write(&big, vec![b' '; (3 << 20) as usize]).unwrap(); let e = crate::project::with_max_bytes(max, || Project::load(&big).unwrap_err()); assert!(e.contains("is 3 MiB, more than a project can be (maximum 2 MiB"), "{e}"); let e = crate::project::with_max_bytes(max, || crate::project::check_format(&" ".repeat(3 << 20), "project").unwrap_err()); assert!(e.contains("too large (maximum 2 MiB"), "{e}"); // Import Maps reads a project file under the same limit: the one saved above, not the one past it. crate::project::with_max_bytes(max, || { assert!(crate::cmd::items::read_text(&json!({"path": path})).is_ok()); let e = crate::cmd::items::read_text(&json!({"path": big})).unwrap_err(); assert!(e.contains("too large"), "{e}"); }); std::fs::remove_dir_all(&dir).ok(); } #[test] fn project_extension_is_rgis_and_legacy_rgisx_opens() { let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-ext-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("project.saveAs", &json!({"path": dir.join("atlas").display().to_string()})).unwrap(); let saved = dir.join("atlas.rgis"); assert!(saved.exists(), "a path without an extension gets .rgis"); let legacy = dir.join("old.rgisx"); std::fs::copy(&saved, &legacy).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": legacy.display().to_string()})).unwrap(); assert_eq!(t.map().layers.len(), 4); for (name, is) in [("a.rgis", true), ("A.RGISX", true), ("a.rgismap", false), ("a.rgispkg", false), ("rgis", false)] { assert_eq!(crate::project::is_project_file(name), is, "{name}"); } for (name, opens) in [("a.rgis", true), ("A.RGISPKG", true), ("rgispkg", false), ("a.geojson", false), ("a.rgismap", false)] { assert_eq!(crate::project::opens_as_project(name), opens, "{name}"); } assert_eq!(crate::project::open_command("dir/a.geojson"), "layer.addData"); std::fs::remove_dir_all(&dir).ok(); } /// Save and Save As write only to a project file: a data file or a package path is refused and /// left as it was, with the session unchanged, while any case of `.rgis`/`.rgisx` saves. /// Opening project JSON under another extension never exempts that path from validation. #[test] fn save_refuses_a_path_that_isnt_a_project_file() { let dir = std::env::temp_dir().join(format!("rustgis-save-ext-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let first = dir.join("atlas.rgis"); s.run("project.saveAs", &json!({"path": first})).unwrap(); let name = s.project.name.clone(); let data = dir.join("roads.geojson"); std::fs::write(&data, b"keep this data").unwrap(); let e = s.run("project.saveAs", &json!({"path": data})).unwrap_err(); assert!(e.contains("a .rgis file, not a .geojson one"), "{e}"); let e = s.run("project.save", &json!({"path": dir.join("data.GPKG")})).unwrap_err(); assert!(e.contains("not a .gpkg one"), "{e}"); let package = dir.join("atlas.rgispkg"); let e = s.run("project.saveAs", &json!({"path": package})).unwrap_err(); assert!(e.contains("project.package"), "{e}"); assert_eq!(std::fs::read(&data).unwrap(), b"keep this data"); assert!(!package.exists() && !dir.join("data.GPKG").exists()); assert_eq!(s.path.as_deref(), Some(first.as_path())); assert_eq!(s.project.name, name, "a refused Save As keeps the project's name"); for ok in ["Upper.RGIS", "old.rgisx"] { s.run("project.saveAs", &json!({"path": dir.join(ok)})).unwrap(); assert!(Project::load(&dir.join(ok)).is_ok(), "{ok}"); } // Opening project JSON under another extension does not permit overwriting it. let kept = dir.join("atlas.json"); std::fs::copy(&first, &kept).unwrap(); s.run("project.open", &json!({"path": kept})).unwrap(); s.run("project.rename", &json!({"name": "renamed"})).unwrap(); let before = std::fs::read(&kept).unwrap(); assert!(s.run("project.save", &Value::Null).is_err()); assert!(s.run("project.saveAs", &json!({"path": kept})).is_err()); assert_eq!(std::fs::read(&kept).unwrap(), before); assert_eq!(s.project.name, "renamed"); assert!(s.dirty()); assert_eq!(s.path.as_deref(), Some(kept.as_path())); std::fs::remove_dir_all(&dir).ok(); } /// A table file picked inside a File Geodatabase stands for the geodatabase, in `layer.addData` /// and `info.fileLayers` alike; through GDAL when it is installed. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_file_inside_a_geodatabase_adds_the_geodatabase() { let dir = std::env::temp_dir().join(format!("rustgis-gdb-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let gdb = dir.join("roads.gdb"); let table = gdb.join("a00000001.gdbtable").display().to_string(); let mut s = Session::new(Project::default()); if rustgis_data::source::gdal::available() { let geojson = dir.join("cities.geojson"); std::fs::write( &geojson, r#"{"type":"FeatureCollection","features":[ {"type":"Feature","properties":{"name":"a"},"geometry":{"type":"Point","coordinates":[8.5,47.4]}}, {"type":"Feature","properties":{"name":"b"},"geometry":{"type":"Point","coordinates":[7.4,46.9]}}]}"#, ) .unwrap(); let made = std::process::Command::new("ogr2ogr") .args(["-f", "OpenFileGDB", "-nln", "cities"]) .arg(&gdb) .arg(&geojson) .status() .is_ok_and(|st| st.success()); if made { let listed = s.run("info.fileLayers", &json!({"path": table})).unwrap(); assert_eq!(listed["layers"], json!(["cities"]), "{listed}"); let added = s.run("layer.addData", &json!({"path": table})).unwrap(); assert_eq!(added["features"], json!(2), "{added}"); std::fs::remove_dir_all(&dir).ok(); return; } } // Without GDAL the geodatabase can't be read, but the error names it, not the table. std::fs::create_dir_all(&gdb).unwrap(); std::fs::write(&table, b"").unwrap(); for cmd in ["layer.addData", "info.fileLayers"] { let e = s.run(cmd, &json!({"path": table})).unwrap_err(); assert!(e.contains("roads.gdb") && !e.contains("gdbtable"), "{cmd}: {e}"); } std::fs::remove_dir_all(&dir).ok(); } #[test] fn save_clears_dirty_and_open_clears_history() { let mut s = world(); assert!(!s.dirty() && !s.can_undo(), "a fresh sample is clean"); s.run("layer.color", &json!({"layer": "Countries", "color": "#336699"})).unwrap(); assert!(s.dirty() && s.can_undo()); let dir = std::env::temp_dir().join(format!("rustgis-dirty-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("a.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); assert!(!s.dirty(), "saving clears the dirty marker"); assert!(s.can_undo(), "saving keeps the edit history"); s.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); assert!(!s.dirty() && !s.can_undo() && !s.can_redo(), "opening starts a fresh history"); assert!(s.run("edit.undo", &Value::Null).is_err()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn degenerate_geometry_and_deleted_fields() { let mut s = world(); for g in [ json!({"type": "LineString", "coordinates": [[0, 0]]}), json!({"type": "MultiPoint", "coordinates": []}), json!({"type": "MultiPolygon", "coordinates": []}), ] { let layer = if g["type"] == "LineString" { "Rivers" } else if g["type"] == "MultiPoint" { "Cities" } else { "Countries" }; assert!(s.run("edit.addFeature", &json!({"layer": layer, "geometry": g})).is_err(), "{g}"); } // A field the definition query or a display filter reads isn't deleted: without it, they // would stop reading and the layer would show the rows they leave out. s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "pop_est > 1000"})).unwrap(); let e = s.run("edit.deleteField", &json!({"layer": "Countries", "field": "pop_est"})).unwrap_err(); assert!(e.contains("definition query (pop_est > 1000) uses 'pop_est'"), "{e}"); assert_eq!(s.map().layer_by_name("Countries").unwrap().definition_query.as_deref(), Some("pop_est > 1000")); // A field the query doesn't read is deleted, and the query keeps the same rows. let shown = |s: &Session| s.map().layer_by_name("Countries").unwrap().visible_rows().iter().filter(|v| **v).count(); let rows = shown(&s); s.run("edit.addField", &json!({"layer": "Countries", "name": "spare", "type": "text"})).unwrap(); s.run("edit.deleteField", &json!({"layer": "Countries", "field": "spare"})).unwrap(); assert_eq!(shown(&s), rows); s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": ""})).unwrap(); // A query that already didn't read (as an imported project can hold) is kept, text and all, // when another field is deleted. let broken = Some("no_such_field > 1".to_string()); s.map_mut().layers.iter_mut().filter(|l| l.name == "Countries").for_each(|l| l.definition_query = broken.clone()); s.run("edit.addField", &json!({"layer": "Countries", "name": "spare2", "type": "text"})).unwrap(); s.run("edit.deleteField", &json!({"layer": "Countries", "field": "spare2"})).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().definition_query, broken); s.map_mut().layers.iter_mut().filter(|l| l.name == "Countries").for_each(|l| l.definition_query = None); let filters = json!({"layer": "Countries", "filters": [{"name": "Big", "where": "pop_est > 1000"}]}); s.run("layer.displayFilters", &filters).unwrap(); let e = s.run("edit.deleteField", &json!({"layer": "Countries", "field": "pop_est"})).unwrap_err(); assert!(e.contains("display filter 'Big' uses 'pop_est'"), "{e}"); // A field the display filter doesn't read is deleted, and the filter is kept as it was. s.run("edit.addField", &json!({"layer": "Countries", "name": "spare3", "type": "text"})).unwrap(); s.run("edit.deleteField", &json!({"layer": "Countries", "field": "spare3"})).unwrap(); let kept = &s.map().layer_by_name("Countries").unwrap().display_filters; assert!(kept.len() == 1 && kept[0].where_ == "pop_est > 1000"); let l = s.map().layer_by_name("Countries").unwrap(); assert!(l.dataset.field_index("pop_est").is_some() && l.display_filters.len() == 1); s.run("layer.displayFilters", &json!({"layer": "Countries", "filters": []})).unwrap(); // Two fields named the same but for case: the query reads the first, which deleting `pop` // removes; it would otherwise go on to read the second without a word. s.run("layer.addDataText", &json!({"fileName": "twins.csv", "text": "lon,lat,Pop,pop\n1,2,5,0\n3,4,0,5\n"})).unwrap(); s.run("layer.definitionQuery", &json!({"layer": "twins", "where": "pop > 1"})).unwrap(); let e = s.run("edit.deleteField", &json!({"layer": "twins", "field": "pop"})).unwrap_err(); assert!(e.contains("definition query (pop > 1) uses 'Pop'"), "{e}"); // The same with a field named like a keyword: `null` reads the field `Null` while it exists, // and would read the other one once it is gone. s.run("layer.addDataText", &json!({"fileName": "keywords.csv", "text": "lon,lat,Null,null\n1,2,5,0\n3,4,0,5\n"})).unwrap(); s.run("layer.definitionQuery", &json!({"layer": "keywords", "where": "null = 5"})).unwrap(); let e = s.run("edit.deleteField", &json!({"layer": "keywords", "field": "null"})).unwrap_err(); assert!(e.contains("definition query (null = 5) uses 'Null'"), "{e}"); s.run("edit.deleteField", &json!({"layer": "Countries", "field": "pop_est"})).unwrap(); let l = s.map().layer_by_name("Countries").unwrap(); assert_eq!(l.renderer.kind_label(), "Single Symbol"); s.run("edit.deleteField", &json!({"layer": "Countries", "field": "name"})).unwrap(); assert!(!s.map().layer_by_name("Countries").unwrap().labels.enabled); } #[test] fn open_from_text() { let mut s = world(); let text = serde_json::to_string(&s.project).unwrap(); s.run("layer.addDataText", &json!({"fileName": "pts.csv", "text": "name,lon,lat\nA,1,2\n"})).unwrap(); assert!(s.map().layer_by_name("pts").is_some()); assert!(s.run("layer.addDataText", &json!({"fileName": "x.shp", "text": ""})).is_err()); assert!(s.run("layer.addDataText", &json!({"fileName": "x.geojson", "text": "{"})).is_err()); assert!(s.run("project.openText", &json!({"text": "not json"})).is_err()); s.run("project.openText", &json!({"text": text})).unwrap(); assert!(s.map().layer_by_name("pts").is_none() && !s.dirty() && !s.can_undo()); } #[test] fn opened_project_is_named_after_its_file() { // Issue #197: a project saved from the sample and renamed kept the sample's name. let mut s = world(); let text = serde_json::to_string(&s.project).unwrap(); s.run("project.openText", &json!({"text": text, "fileName": "test.rgis"})).unwrap(); assert_eq!(s.project.name, "test"); s.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(s.project.name, "World Atlas", "without a file name the project keeps its own"); s.run("project.openText", &json!({"text": text, "fileName": " .rgis"})).unwrap(); assert_eq!(s.project.name, "World Atlas", "an empty file name doesn't rename it"); let dir = std::env::temp_dir().join(format!("rustgis-named-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("Trip plan.rgis"); std::fs::write(&path, &text).unwrap(); s.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(s.project.name, "Trip plan"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn command_ids_are_unique() { let s = Session::new(Project::default()); let mut ids: Vec<&str> = s.registry.commands.iter().map(|c| c.id).collect(); ids.sort_unstable(); let n = ids.len(); ids.dedup(); assert_eq!(n, ids.len()); } /// A 3-band 8×6 UTM 17N GeoTIFF near 81°W, 42°N, as base64. fn utm_tiff_b64() -> String { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: 497_000.0, origin_y: 4_651_000.0, pixel_width: 500.0, pixel_height: -500.0 }; let bands = (0..3).map(|b| (0..48).map(|i| ((i * 5 + b * 40) % 256) as f32).collect()).collect(); let r = Raster::new("utm", 8, 6, bands, gt, Crs::from_epsg(32617).unwrap(), None, ColorHint::Rgb { alpha: false }, "UInt8").unwrap(); base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()) } /// The sample world with a streamed layer active. fn with_streamed(path: &std::path::Path) -> Session { let mut s = world(); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "Streamed"})).unwrap(); s } fn with_raster() -> Session { let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": utm_tiff_b64()})).unwrap(); s } /// A QGIS raster style colours a raster layer; one reading a band the raster hasn't is noted and /// leaves it as it was. #[test] fn a_qgis_raster_style_colours_a_raster() { let mut s = with_raster(); let qml = r#" "#; let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); assert_eq!(out["notes"], json!([])); let l = s.map().layer_by_name("scene").unwrap(); let st = &l.raster.as_ref().unwrap().style; assert_eq!( (&st.renderer, st.contrast, l.transparency), (&rustgis_map::raster::RasterRenderer::Rgb { red: 2, green: 1, blue: 0 }, 25.0, 50.0) ); let before = st.clone(); // A style that reads a band the raster hasn't leaves it as it was, its opacity too. let qml = r#"0.2"#; let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); assert!(serde_json::to_string(&out["notes"]).unwrap().contains("band 5 of a raster with 3"), "{out}"); let l = s.map().layer_by_name("scene").unwrap(); assert_eq!((&l.raster.as_ref().unwrap().style, l.transparency), (&before, 50.0)); // Pixels not there yet (a remote raster still loading): applied, and noted as unchecked. let id = s.map().layer_by_name("scene").unwrap().id; s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().data = None; let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); assert!(serde_json::to_string(&out["notes"]).unwrap().contains("before the raster's bands could be checked"), "{out}"); } /// The parts of a large GeoTIFF the web app reads (#140), opened and kept in a project: a /// cloud-optimised file read a part at a time, as `parts.rs` reads a picked file. #[test] fn geotiff_parts_open_and_survive_a_saved_project() { use base64::Engine; use rustgis_data::raster::cog::{self, Sparse}; use rustgis_data::raster::geotiff::{self, Partial}; let file = cog::example(1024, 256); let len = file.len() as u64; let mut parts = Sparse::default().with_len(len).with(0, file[..4096].to_vec()); let mut done = false; for _ in 0..100 { match geotiff::read_sparse(&parts, "scene").unwrap() { Partial::Done(_) => { done = true; break; } Partial::Need(a, b) => { let end = b.max(a + (64 << 10)).min(len).min(parts.next_start(a).unwrap_or(len)); parts = parts.with(a, file[a as usize..end as usize].to_vec()); } } } assert!(done); let container = parts.container(); let mut s = world(); let data = base64::engine::general_purpose::STANDARD.encode(&container); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": data})).unwrap(); let value = |s: &Session| s.map().layer_by_name("scene").and_then(|l| l.raster.as_ref()?.data.as_ref()?.value(0, 300, 200)); // Pixel (x, y) of the example is (x + y) % 256. assert_eq!(value(&s), Some(244.0)); // Saved, the project keeps the parts, not the whole file, and opens the raster again. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.len() < file.len() * 2, "{} for a {}-byte file", text.len(), file.len()); s.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(value(&s), Some(244.0)); } /// File-supplied items reject implicit network and reserved actions before their handlers run, /// while ordinary in-project cells still run. This uses the existing Wasm plugin fixture. #[test] fn imported_tasks_and_notebooks_reject_network_and_reserved_actions() { let mut s = world(); plugin("eng-item-containment", &[]); for (command, params) in [ ("layer.geocode", json!({"layer": "Cities", "address": "name"})), ("connection.test", json!({"id": "saved-connection"})), ("plugin.uninstall", json!({"id": "eng-item-containment"})), ] { let steps = json!([{"title": "Outside", "command": command, "params": params}]); let task = json!({"name": "From a file", "steps": steps}); let why = s.run("task.import", &json!({"text": task.to_string()})).unwrap_err(); assert!(why.contains("network") && why.contains("imported"), "{why}"); // Opened projects and later edits reach the execution check without task.import. s.run("task.new", &json!({"name": command, "steps": steps, "imported": true})).unwrap(); let why = s.run("task.run", &json!({"task": command})).unwrap_err(); assert!(why.contains("from a file"), "{why}"); } let text = json!({"name": "Shared", "cells": [ {"kind": "code", "source": "select.all {\"layer\":\"Cities\"}"}, {"kind": "code", "source": "plugin.uninstall {\"id\":\"eng-item-containment\"}"}, {"kind": "code", "source": "layer.geocode {\"layer\":\"Cities\",\"address\":\"name\"}"} ]}) .to_string(); s.run("notebook.open", &json!({"text": text})).unwrap(); let result = s.run("notebook.run", &json!({"notebook": "Shared"})).unwrap(); assert_eq!(result["error"], 1); let notebook = s.project.notebooks.iter().find(|n| n.name == "Shared").unwrap(); assert!(!notebook.cells[0].error); assert!(notebook.cells[1].error && notebook.cells[1].output.as_deref().unwrap().contains("from a file")); assert!(!s.map().layer_by_name("Cities").unwrap().selection.is_empty()); let result = s.run("notebook.run", &json!({"notebook": "Shared", "cell": 2})).unwrap(); assert_eq!(result["error"], 2); assert!(rustgis_plugins::get("eng-item-containment").is_some(), "no uninstall handler ran"); rustgis_plugins::unload("eng-item-containment"); } /// Plugin tools are rejected through all imported item runners, including models' direct tool /// paths. A locally authored model still runs the same real plugin tool. #[test] fn imported_items_reject_resolved_plugin_tools() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::imported_items_reject_resolved_plugin_tools") { return; } let mut s = world(); plugin("eng-tool-containment", &[]); let tool = "plugin:eng-tool-containment/m"; let params = json!({"input": "Cities"}); let gp = json!({"tool": tool, "params": params}); let before = s.map().layers.len(); let task = json!({"name": "Plugin task", "steps": [{"title": "Run", "command": "gp.run", "params": gp}]}); assert!(s.run("task.import", &json!({"text": task.to_string()})).unwrap_err().contains("imported")); let notebook = json!({"name": "Plugin notebook", "cells": [{"kind": "code", "source": format!("gp.run {gp}")}]}); s.run("notebook.open", &json!({"text": notebook.to_string()})).unwrap(); assert_eq!(s.run("notebook.run", &json!({"notebook": "Plugin notebook"})).unwrap()["error"], 0); let graph = json!({"nodes": [{"id": "x", "kind": "tool", "tool": tool, "params": params}], "edges": []}); for (name, body) in [("Plugin steps", json!({"steps": [{"tool": tool, "params": params}]})), ("Plugin graph", json!({"graph": graph}))] { let mut model = body; model["name"] = json!(name); s.run("model.import", &json!({"text": model.to_string()})).unwrap(); let why = s.run("model.run", &json!({"name": name})).unwrap_err(); assert!(why.contains("from a file"), "{why}"); } assert_eq!(s.map().layers.len(), before, "no untrusted tool ran"); // The same graph made here is authorized; no plugin interface or manifest policy changes. s.run("model.new", &json!({"name": "Made here", "graph": graph})).unwrap(); s.run("model.run", &json!({"name": "Made here"})).unwrap(); assert_eq!(s.map().layers.len(), before + 1); assert!(s.map().layer_by_name("Made").is_some()); rustgis_plugins::unload("eng-tool-containment"); } #[test] fn imported_tasks_name_outputs_and_steps_are_edited() { let mut s = Session::new(sample::world_project()); // A task from a file may name a tool's output layer, but not write to a path through it. let lesson = json!({"name": "Lesson", "steps": [ {"title": "Buffer the cities", "instructions": "Analyze › Tools › Buffer", "command": "gp.run", "params": {"tool": "buffer", "params": {"input": "Cities", "distance": 100, "output": "Near cities"}}}, {"title": "Zoom", "command": "layer.zoomTo", "params": {"layer": "Near cities"}}, {"title": "Clear", "command": "select.clear"} ]}); s.run("task.import", &json!({"text": lesson.to_string()})).unwrap(); s.run("task.run", &json!({"task": "Lesson", "step": 0})).unwrap(); assert!(s.map().layer_by_name("Near cities").is_some()); let to_a_file = json!({"name": "To a file", "steps": [{"title": "Buffer", "command": "gp.run", "params": {"tool": "buffer", "params": {"input": "Cities", "distance": 100, "output": "C:/temp/x.shp"}}}]}); assert!(s.run("task.import", &json!({"text": to_a_file.to_string()})).is_err()); assert!(crate::cmd::items::touches_files(&s, "gp.run", &json!({"tool": "buffer", "params": {"output": "../x.shp"}}))); assert!(!crate::cmd::items::touches_files(&s, "gp.run", &json!({"tool": "buffer", "params": {"output": "Near cities"}}))); // Spatial Join and Zonal Statistics run their built-in tool with the parameters flat: a // plain output name stays in the session there too. assert!(!crate::cmd::items::touches_files(&s, "gp.spatialJoin", &json!({"target": "Cities", "join": "Countries", "output": "Joined"}))); assert!(!crate::cmd::items::touches_files(&s, "gp.zonalStats", &json!({"raster": "dem", "polygons": "Countries", "output": "Zones"}))); // Only a plain layer name, only gp.run's (and those two's), only for RustGIS's own tools. for (command, params) in [ ("gp.run", json!({"tool": "buffer", "params": {"output": "x.shp"}})), ("gp.run", json!({"tool": "buffer", "params": {"output": "sub/x"}})), ("gp.run", json!({"tool": "buffer", "params": {"output": "sub\\x"}})), ("gp.run", json!({"tool": "buffer", "params": {"output": ".bashrc"}})), ("gp.run", json!({"tool": "geolibre:slope", "params": {"output": "slope"}})), ("gp.run", json!({"tool": "plugin:kit/hex", "params": {"output": "hex"}})), // Not one of RustGIS's own tools, even without a prefix. ("gp.run", json!({"tool": "someOtherTool", "params": {"output": "x"}})), ("gp.run", json!({"tool": "buffer", "params": {"input": "Cities", "nested": {"output": "x"}}})), ("layer.rename", json!({"layer": "Cities", "output": "x"})), ("gp.spatialJoin", json!({"target": "Cities", "join": "Countries", "output": "C:/temp/x.shp"})), ("gp.zonalStats", json!({"raster": "dem", "polygons": "Countries", "output": "../zones"})), ] { assert!(crate::cmd::items::touches_files(&s, command, ¶ms), "{command} {params}"); } // `"step": null` runs the whole task, as before; a step that isn't an index is refused. s.run("select.all", &json!({"layer": "Cities"})).unwrap(); let r = s.run("task.run", &json!({"task": "Lesson", "step": null})).unwrap(); assert_eq!(r["ran"].as_array().unwrap().len(), 3); assert!(s.run("task.run", &json!({"task": "Lesson", "step": "two"})).is_err()); // Steps are edited, moved and deleted, each one undoable. s.run( "task.editStep", &json!({"task": "Lesson", "step": 1, "title": " Look at it ", "instructions": "Line one. Line two."}), ) .unwrap(); let step = |s: &Session, i: usize| s.project.tasks.iter().find(|t| t.name == "Lesson").unwrap().steps[i].clone(); assert_eq!( (step(&s, 1).title.as_str(), step(&s, 1).instructions.as_str()), ( "Look at it", "Line one. Line two." ) ); assert_eq!(step(&s, 1).command, "layer.zoomTo", "editing keeps the command"); assert!(s.run("task.editStep", &json!({"task": "Lesson", "step": 1, "title": " "})).is_err()); assert!(s.run("task.editStep", &json!({"task": "Lesson", "step": 9, "title": "x"})).is_err()); s.run("task.moveStep", &json!({"task": "Lesson", "step": 2, "to": 0})).unwrap(); assert_eq!(step(&s, 0).title, "Clear"); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(step(&s, 0).title, "Buffer the cities"); s.run("task.removeStep", &json!({"task": "Lesson", "step": 2})).unwrap(); assert_eq!(s.project.tasks.iter().find(|t| t.name == "Lesson").unwrap().steps.len(), 2); assert!(s.run("task.moveStep", &json!({"task": "Lesson", "step": 0, "to": 5})).is_err()); // Neither changes nothing (which would leave an undo step). let depth = s.undo_depth(); assert!(s.run("task.moveStep", &json!({"task": "Lesson", "step": 1, "to": 1})).is_err()); let bad = s.run("task.removeStep", &json!({"task": "Lesson", "step": -1})).unwrap_err(); assert!(bad.contains("whole number"), "{bad}"); assert!(s.run("task.editStep", &json!({"task": "Lesson", "step": 1})).is_err()); let same = s.project.tasks.iter().find(|t| t.name == "Lesson").unwrap().steps[1].title.clone(); assert!(s.run("task.editStep", &json!({"task": "Lesson", "step": 1, "title": format!(" {same} ")})).is_err()); assert_eq!(s.undo_depth(), depth); // Still a task from a file: a step that writes a file stays refused after editing. assert!(!s.item_trusted("task", "lesson")); } #[test] fn tissot_indicatrix_reports_the_maps_own_distortion() { let mut s = Session::new(sample::world_project()); let field = |s: &Session, layer: &str, lat: f64, name: &str| -> Value { let ds = &s.map().layer_by_name(layer).unwrap().dataset; let col = |n: &str| ds.fields.iter().position(|f| f.name == n).unwrap(); let row = (0..ds.features.len()) .find(|&r| { ds.value(r, col("lat")) == &rustgis_data::Value::Float(lat) && ds.value(r, col("lon")) == &rustgis_data::Value::Float(0.0) }) .unwrap(); match ds.value(row, col(name)) { rustgis_data::Value::Float(v) => json!(v), rustgis_data::Value::Text(t) => json!(t), v => panic!("{name}: {v:?}"), } }; // Web Mercator, the default: area grows to about sec² 60° = 4, shapes are kept. s.run("gp.run", &json!({"tool": "tissot", "params": {"output": "merc"}})).unwrap(); assert_eq!(field(&s, "merc", 60.0, "projection"), json!("Web Mercator")); let area = field(&s, "merc", 60.0, "area_scale").as_f64().unwrap(); assert!((area - 4.0).abs() < 0.05, "{area}"); assert!(field(&s, "merc", 60.0, "max_angle").as_f64().unwrap() < 0.5); // Mollweide is equal-area: about 1 everywhere, while angles bend away from its centre. s.run("map.projection", &json!({"type": "mollweide"})).unwrap(); s.run("gp.run", &json!({"tool": "tissot", "params": {"output": "moll"}})).unwrap(); for lat in [-60.0, 0.0, 30.0, 60.0] { let area = field(&s, "moll", lat, "area_scale").as_f64().unwrap(); assert!((area - 1.0).abs() < 0.01, "{lat}: {area}"); } assert!(field(&s, "moll", 60.0, "max_angle").as_f64().unwrap() > 10.0); // On the globe there's no flat projection to measure: circles only. s.run("map.projection", &json!({"type": "globe"})).unwrap(); s.run("gp.run", &json!({"tool": "tissot", "params": {"output": "globe"}})).unwrap(); let fields: Vec = s.map().layer_by_name("globe").unwrap().dataset.fields.iter().map(|f| f.name.clone()).collect(); assert_eq!(fields, ["lat", "lon"]); // Map Extent on the globe fills the whole Earth. s.run("gp.run", &json!({"tool": "tissot", "params": {"area": "Map Extent", "spacing": 60, "output": "globeView"}})).unwrap(); // Six columns of 60° and three rows (−60°, 0°, 60°; the poles' rows are left out). assert_eq!(s.map().layer_by_name("globeView").unwrap().dataset.features.len(), 18); } /// A5 grids (#551) through gp.run: a world grid of equal-area cells, and the sample's cities /// counted by cell, every city in one. #[test] fn a5_grids_and_points_by_cell() { let mut s = world(); let r = s.run("gp.run", &json!({"tool": "a5Grid", "params": {"resolution": 1, "area": "World", "output": "cells"}})).unwrap(); let cells = s.map().layer_by_name("cells").unwrap_or_else(|| panic!("{r}")); let names: Vec<&str> = cells.dataset.fields.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["a5", "resolution", "area_km2"]); assert!(cells.dataset.features.len() > 12); let cities = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); s.run("gp.run", &json!({"tool": "a5Aggregate", "params": {"input": "Cities", "resolution": 2, "output": "by cell"}})).unwrap(); let by = &s.map().layer_by_name("by cell").unwrap().dataset; let i = by.field_index("count").unwrap(); let total: f64 = (0..by.features.len()).map(|r| by.value(r, i).as_f64().unwrap()).sum(); assert_eq!(total as usize, cities); assert!(s.run("gp.run", &json!({"tool": "a5Grid", "params": {"resolution": 13, "area": "World"}})).is_err()); } #[test] fn globe_projection_toggles_renders_and_saves() { let mut s = with_raster(); assert_eq!(s.run("map.projection", &Value::Null).unwrap()["projection"], "globe"); assert_eq!(s.map().projection, rustgis_map::Projection::Globe); // The globe draws vectors, rasters and labels, from any side. for (lon, lat, res) in [(-81.0, 42.0, 30_000.0), (100.0, -60.0, 60_000.0), (-81.0, 42.0, 50.0), (0.0, 89.0, 160_000.0)] { s.map_mut().camera = rustgis_map::Camera { center: rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(lon, lat)), res, bearing: 0.0, pitch: 0.0 }; let png = crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap(); assert!(png.len() > 1000); } assert!(s.run("map.projection", &json!({"type": "nope"})).is_err()); s.run("map.projection", &json!({"type": "mercator"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().projection, rustgis_map::Projection::Globe, "undo restores the globe"); let json = serde_json::to_string(&s.project).unwrap(); assert!(Project::from_json(&json).unwrap().map().projection == rustgis_map::Projection::Globe); } /// `map.projection` takes ESRI codes (#213): South America Albers, the issue's regional map. #[test] fn esri_projection_codes() { let mut s = world(); s.run("map.goToXY", &json!({"lon": -60.0, "lat": -15.0, "scale": 20_000_000})).unwrap(); let r = s.run("map.projection", &json!({"type": "esri:102033"})).unwrap(); assert_eq!(r["projection"], "ESRI:102033"); assert_eq!(r["label"], "ESRI:102033"); let c = s.map().center_lonlat(); assert!((c.x + 60.0).abs() < 1e-6 && (c.y + 15.0).abs() < 1e-6, "{c:?}"); assert!(crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap().len() > 1000); let json = serde_json::to_string(&s.project).unwrap(); assert_eq!(Project::from_json(&json).unwrap().map().projection.code(), "ESRI:102033"); // A code the table leaves out is refused, and the map keeps its projection. let e = s.run("map.projection", &json!({"type": "ESRI:54003"})).unwrap_err(); assert!(e.contains("ESRI:54003"), "{e}"); assert_eq!(s.map().projection.code(), "ESRI:102033"); // ESRI's Web Mercator is the default map. assert_eq!(s.run("map.projection", &json!({"type": "ESRI:102100"})).unwrap()["projection"], "mercator"); } /// Presets take a central meridian, and the conics their standard parallels too (#517). The map /// keeps them, and a bad value changes nothing. #[test] fn projection_presets_take_parameters() { let mut s = world(); let r = s.run("map.projection", &json!({"type": "robinson", "lon_0": 150})).unwrap(); assert_eq!(r["projection"], "robinson +lon_0=150"); assert_eq!(r["label"], "Robinson (lon_0=150)"); // Without a type, the values go to the map's own preset. A code with them, as typed, is the same. s.run("map.projection", &json!({"lon_0": -20})).unwrap(); assert_eq!(s.map().projection.code(), "robinson +lon_0=-20"); s.run("map.projection", &json!({"type": "Albers Equal Area Conic +lat_2=-35 +lon_0=-55 +lat_1=-5"})).unwrap(); assert_eq!(s.map().projection.code(), "albers +lon_0=-55 +lat_1=-5 +lat_2=-35"); s.run("map.projection", &json!({"lat_1": 0})).unwrap(); assert_eq!(s.map().projection.code(), "albers +lon_0=-55 +lat_1=0 +lat_2=-35"); // The preset's own value drops out. s.run("map.projection", &json!({"type": "robinson", "lon_0": 0})).unwrap(); assert_eq!(s.map().projection.code(), "robinson"); // Out of range, a parameter the preset doesn't take, not a number, parameters on a code or on // Web Mercator, and parallels that make no cone: refused, and the map keeps its projection. s.run("map.projection", &json!({"type": "robinson", "lon_0": 150})).unwrap(); for bad in [ json!({"lon_0": 200}), json!({"lat_1": 10}), json!({"lon_0": "east"}), json!({"type": "EPSG:3035", "lon_0": 10}), json!({"type": "mercator", "lon_0": 10}), json!({"type": "robinson +lon_0=999"}), json!({"type": "albers", "lat_1": -30, "lat_2": 30}), ] { assert!(s.run("map.projection", &bad).is_err(), "{bad}"); assert_eq!(s.map().projection.code(), "robinson +lon_0=150", "{bad}"); } // Saved with the map, and drawn. let json = serde_json::to_string(&s.project).unwrap(); assert_eq!(Project::from_json(&json).unwrap().map().projection.code(), "robinson +lon_0=150"); let png = crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap(); assert!(png.len() > 1000); } #[test] fn vn2000_projection_command_and_project_persistence() { let mut s = world(); // Explicit prefixed province syntax works and canonicalizes to the preset s.run("map.projection", &json!({"type": "vn2000:danang"})).unwrap(); assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75"); assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)"); // Capital Hanoi on central meridian 105.0 drops redundant parameter s.run("map.projection", &json!({"type": "vn2000:hanoi"})).unwrap(); assert_eq!(s.map().projection.code(), "vn2000tm3"); assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam)"); // UTM zones work s.run("map.projection", &json!({"type": "vn2000Utm48"})).unwrap(); assert_eq!(s.map().projection.code(), "vn2000Utm48"); assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 48N"); s.run("map.projection", &json!({"type": "vn2000Utm49"})).unwrap(); assert_eq!(s.map().projection.code(), "vn2000Utm49"); assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 49N"); // Bare province names and "vietnam" are rejected in strict command context for bare in ["danang", "vinh", "hanoi", "hue", "hcm", "vietnam"] { assert!(s.run("map.projection", &json!({"type": bare})).is_err(), "{bare}"); } // Ambiguous bare VN-2000 is rejected for bare in ["vn2000", "VN-2000", "VN2000"] { let err = s.run("map.projection", &json!({"type": bare})).unwrap_err(); assert!(err.contains("ambiguous"), "{bare}: {err}"); } // Set a provincial TM-3 projection and verify project save and reload s.run("map.projection", &json!({"type": "VN-2000 / Đà Nẵng"})).unwrap(); assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75"); let text = serde_json::to_string(&s.project).unwrap(); // Reload project from serialized JSON text let mut s2 = world(); s2.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(s2.map().projection.code(), "vn2000tm3 +lon_0=107.75"); assert_eq!(s2.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)"); } #[test] fn custom_projections_switch_keep_scale_query_and_render() { let mut s = with_raster(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 5_000_000})).unwrap(); let before = s.scale(); let r = s.run("map.projection", &json!({"type": "europeEqualArea"})).unwrap(); assert_eq!(r["projection"], "europeEqualArea"); assert!((s.scale() / before - 1.0).abs() < 0.02, "scale kept: {before} vs {}", s.scale()); let c = s.map().center_lonlat(); assert!((c.x - 10.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6, "{c:?}"); // Identify and box select work in the projection's coordinates. let v = s.run("identify", &json!({"lon": 2.35, "lat": 48.85, "tolerancePx": 5})).unwrap(); assert!(!v.is_null(), "something is under Paris"); let sel = s.run("select.byRect", &json!({"minLon": 5.0, "minLat": 45.0, "maxLon": 15.0, "maxLat": 55.0})).unwrap(); assert!(sel["selected"].as_u64().unwrap() > 0); assert!(crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap().len() > 1000); // An EPSG code; the raster (UTM 17N) in the map's own system draws without reprojection. s.run("map.projection", &json!({"type": "EPSG:32617"})).unwrap(); s.run("layer.zoomTo", &json!({"layer": "scene"})).unwrap(); assert!(crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap().len() > 1000); assert!(s.run("map.projection", &json!({"type": "EPSG:4326"})).is_err(), "degrees are refused"); assert!(s.run("map.projection", &json!({"type": "nope"})).is_err()); // A known code whose method isn't implemented (EPSG:8441, Laborde) is refused, not drawn in // Web Mercator under its own name; the map keeps its projection (#208). proj4rs 0.3 grew // Brazil Polyconic, so EPSG:5880 switches now. let e = s.run("map.projection", &json!({"type": "EPSG:8441"})).unwrap_err(); assert!(e.contains("labrd"), "{e}"); assert_eq!(s.map().projection.code(), "EPSG:32617"); assert!(s.run("map.goToXY", &json!({"lon": 100.0, "lat": 0.0})).is_err(), "folded far side of the UTM zone"); // Undo and save/reload keep the projection. s.run("map.projection", &json!({"type": "mollweide"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().projection.code(), "EPSG:32617"); let json = serde_json::to_string(&s.project).unwrap(); assert_eq!(Project::from_json(&json).unwrap().map().projection.code(), "EPSG:32617"); assert!(s.run("map.projections", &Value::Null).unwrap().as_array().unwrap().len() >= 10); // A project naming a projection this version can't use opens in Web Mercator. let broken = json.replace("EPSG:32617", "EPSG:9"); let reopened = Project::from_json(&broken).unwrap(); assert_eq!(reopened.map().projection, rustgis_map::Projection::WebMercator); assert_eq!(reopened.map().camera, rustgis_map::Camera::world(), "UTM metres aren't read as Mercator ones"); // Bookmarks follow a projection switch, and the extent history doesn't replay old cameras. s.run("map.projection", &json!({"type": "mercator"})).unwrap(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 5_000_000})).unwrap(); // The sample has a "Europe" already: the new one is numbered (#351), and found by its name. let europe = s.run("bookmark.add", &json!({"name": "Europe"})).unwrap()["name"].clone(); assert_eq!(europe, "Europe 2"); s.run("map.goToXY", &json!({"lon": -100.0, "lat": 40.0})).unwrap(); assert!(s.can_extent_back()); s.run("map.projection", &json!({"type": "europeEqualArea"})).unwrap(); assert!(!s.can_extent_back(), "history from Web Mercator is dropped"); s.run("bookmark.go", &json!({"name": europe})).unwrap(); let c = s.map().center_lonlat(); assert!((c.x - 10.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6, "{c:?}"); assert!((s.scale() / 5_000_000.0 - 1.0).abs() < 0.02, "{}", s.scale()); // An export at another size measures its scale bar with its own camera. let wide = s.map().ground_res_at(&rustgis_map::Camera { res: s.map().camera.res * 3.0, ..s.map().camera }); assert!((wide / s.map().ground_res() - 3.0).abs() < 1e-9); // The built-in world projections (and Equal Earth by its EPSG code) draw the whole world. for p in ["robinson", "equalEarth", "naturalEarth", "winkelTripel", "eckertIV", "EPSG:8857"] { s.run("map.projection", &json!({"type": p})).unwrap_or_else(|e| panic!("{p}: {e}")); s.run("map.zoomFull", &Value::Null).unwrap(); let c = s.map().center_lonlat(); assert!(c.x.abs() < 1.0 && c.y.abs() < 30.0, "{p}: {c:?}"); let v = s.run("identify", &json!({"lon": 2.35, "lat": 48.85, "tolerancePx": 5})).unwrap(); assert!(!v.is_null(), "{p}: something is under Paris"); assert!(crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap().len() > 1000, "{p}"); } } #[test] fn raster_layers_open_style_identify_and_round_trip() { let mut s = with_raster(); let l = s.map().layers.first().unwrap(); assert!(l.is_raster(), "a new raster goes on top, as every new layer does (#808)"); assert_eq!(l.name, "scene"); assert_eq!(s.active_layer, Some(l.id)); let e = l.extent(); assert!((e.center().x + 81.0).abs() < 0.1 && (e.center().y - 42.0).abs() < 0.1, "{e:?}"); // Rendering, stretches and enhancement. for p in [ json!({"type": "stretch", "band": 2, "ramp": "Terrain"}), json!({"type": "classify", "classes": 4, "method": "quantile"}), json!({"type": "unique"}), json!({"type": "shadedRelief", "azimuth": 270}), json!({"type": "rgb"}), ] { s.run("raster.symbology", &p).unwrap_or_else(|e| panic!("{p}: {e}")); } // Bounds typed by hand are for Graduated Colors: a raster's classes are worked out. assert!(s.run("raster.symbology", &json!({"type": "classify", "method": "manual"})).is_err()); assert!(s.run("raster.symbology", &json!({"type": "colormap"})).is_err()); assert!(s.run("raster.symbology", &json!({"band": 9})).is_err()); s.run("raster.bands", &json!({"red": 3, "green": 2, "blue": 1})).unwrap(); s.run("raster.symbology", &json!({"type": "rgb"})).unwrap(); // keeps the combination assert!(s.run("raster.bands", &json!({"preset": "colorInfrared"})).is_err(), "no band 4"); s.run("raster.stretch", &json!({"type": "standardDeviation", "n": 2.5})).unwrap(); s.run("raster.dra", &json!({"on": true})).unwrap(); s.run("raster.resampling", &json!({"type": "bilinear"})).unwrap(); s.run("raster.enhance", &json!({"brightness": 20, "contrast": -10, "gamma": 1.4})).unwrap(); s.run("layer.ramp", &json!({"ramp": "Aurora"})).unwrap(); let st = s.map().layers.first().unwrap().raster.as_ref().unwrap().style.clone(); assert_eq!(st.renderer, rustgis_map::RasterRenderer::Rgb { red: 2, green: 1, blue: 0 }); assert!(st.dra && st.gamma == 1.4); // Feature-only commands are off for rasters, and layer conditions follow a named layer. assert!(s.run("table.open", &Value::Null).is_err()); assert!(s.run("labels.enable", &json!({"layer": "scene"})).is_err()); s.run("layer.symbology", &json!({"layer": "Countries", "type": "single"})).unwrap(); s.active_layer = s.map().layer_by_name("Countries").map(|l| l.id); s.run("raster.info", &json!({"layer": "scene"})).unwrap(); s.active_layer = s.map().layer_by_name("scene").map(|l| l.id); // Identify reports features first (Lake Erie is here), then pixel values. let v = s.run("identify", &json!({"lon": -81.0, "lat": 42.0})).unwrap(); assert_eq!(v["layer"], "Lakes", "{v}"); for l in ["Cities", "Rivers", "Lakes", "Countries"] { s.run("layer.visibility", &json!({"layer": l, "visible": false})).unwrap(); } let v = s.run("identify", &json!({"lon": -81.0, "lat": 42.0})).unwrap(); assert_eq!(v["raster"], true, "{v}"); assert!(v["attributes"]["Band 1"].is_string()); let info = s.run("raster.info", &Value::Null).unwrap(); assert_eq!(info["crs"], "EPSG:32617"); assert_eq!(info["bands"].as_array().map(Vec::len), Some(3)); // Export, then reopen from the file. let dir = std::env::temp_dir().join(format!("rustgis-raster-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let tif = dir.join("out.tif"); s.run("share.exportRaster", &json!({"path": tif.display().to_string()})).unwrap(); s.run("layer.addData", &json!({"path": tif.display().to_string()})).unwrap(); assert!(s.map().layer_by_name("out").is_some_and(|l| l.raster.as_ref().is_some_and(|r| r.path.is_some()))); // Save and reload the project: the upload is embedded, the file is reopened by path. let proj = dir.join("p.rgis"); s.run("project.saveAs", &json!({"path": proj.display().to_string()})).unwrap(); s.run("project.open", &json!({"path": proj.display().to_string()})).unwrap(); for name in ["scene", "out"] { let l = s.map().layer_by_name(name).unwrap(); assert!(l.raster.as_ref().unwrap().data.is_some(), "{name} reloaded"); } assert!(s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style.dra, "style kept"); // Undo after a style change restores the previous style. s.active_layer = s.map().layer_by_name("scene").map(|l| l.id); s.run("raster.dra", &json!({"on": false})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style.dra); let _ = std::fs::remove_dir_all(&dir); // The map renders with rasters. assert!(crate::cmd::share::map_png(&mut s, Some(320.0), Some(200.0), None).is_ok()); } /// #348: `raster.mask` hides pixel values on the fly: values drawn as no data and a range, /// kept in the project and undone like any style change. #[test] fn raster_masks_hide_values() { let mut s = with_raster(); let style = |s: &Session| s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style.clone(); let info = s.run("raster.info", &Value::Null).unwrap(); let (lo, hi) = (info["bands"][0]["min"].as_f64().unwrap(), info["bands"][0]["max"].as_f64().unwrap()); assert!(info["mask"].is_null()); s.run("raster.mask", &json!({"values": format!("{lo}, {lo} ; {hi}")})).unwrap(); assert_eq!(style(&s).mask.values, vec![lo, hi], "text, sorted and without repeats"); let rl = s.map().layer_by_name("scene").unwrap().raster.clone().unwrap(); let st = rl.masked_stats().unwrap(); assert!(st[0].min > lo && st[0].max < hi, "{:?}", st[0]); // A range on top, then the other way round; a bound given as null is removed. s.run("raster.mask", &json!({"min": lo + 1.0, "max": hi - 1.0})).unwrap(); s.run("raster.mask", &json!({"inside": true, "max": null})).unwrap(); let m = style(&s).mask; assert_eq!((m.min, m.max, m.inside, m.values.len()), (Some(lo + 1.0), None, true, 2)); assert!(s.run("raster.info", &Value::Null).unwrap()["mask"].as_str().is_some_and(|t| t.contains('≥'))); // Refused: a reversed range, values that aren't numbers, too many values. assert!(s.run("raster.mask", &json!({"min": 5, "max": 1})).is_err()); assert!(s.run("raster.mask", &json!({"values": "1, x"})).is_err()); assert!(s.run("raster.mask", &json!({"values": [1, "nan"]})).is_err()); assert!(s.run("raster.mask", &json!({"min": "x"})).is_err()); assert!(s.run("raster.mask", &json!({"values": (0..300).collect::>()})).is_err()); assert_eq!(style(&s).mask, m, "nothing changed by a refused mask"); // Class breaks follow the pixels shown. s.run("raster.mask", &json!({"clear": true, "min": lo, "max": (lo + hi) / 2.0})).unwrap(); s.run("raster.symbology", &json!({"type": "classify", "classes": 4})).unwrap(); let top = |s: &Session| match style(s).renderer { rustgis_map::RasterRenderer::Classify { breaks, .. } => breaks.last().copied().unwrap_or_default(), _ => f64::NAN, }; assert!(top(&s) <= (lo + hi) / 2.0, "{}", top(&s)); s.run("raster.mask", &json!({"max": null})).unwrap(); assert!(top(&s) > (lo + hi) / 2.0, "worked out again without the bound"); // Masking down to one value collapses the classes; clearing the mask brings back the 4 asked for. let classes = |s: &Session| match style(s).renderer { rustgis_map::RasterRenderer::Classify { breaks, .. } => breaks.len(), _ => 0, }; s.run("raster.mask", &json!({"min": lo, "max": lo})).unwrap(); assert_eq!(classes(&s), 1); s.run("raster.mask", &json!({"clear": true})).unwrap(); assert_eq!(classes(&s), 4); // Undo the clear and the one-value range. s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); // Breaks edited by hand are kept. let mut edited = style(&s); if let rustgis_map::RasterRenderer::Classify { breaks, .. } = &mut edited.renderer { breaks[0] += 0.5; } let id = s.map().layer_by_name("scene").unwrap().id; s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().style = edited.clone(); s.run("raster.mask", &json!({"values": [lo]})).unwrap(); assert_eq!(style(&s).renderer, edited.renderer); // Undo that mask. s.run("edit.undo", &Value::Null).unwrap(); // Undo removing the max bound; then saved and drawn. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(style(&s).mask.max, Some((lo + hi) / 2.0)); let dir = std::env::temp_dir().join(format!("rustgis-mask-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let proj = dir.join("p.rgis"); s.run("project.saveAs", &json!({"path": proj.display().to_string()})).unwrap(); s.run("project.open", &json!({"path": proj.display().to_string()})).unwrap(); assert_eq!(style(&s).mask.max, Some((lo + hi) / 2.0), "kept in the project"); let _ = std::fs::remove_dir_all(&dir); assert!(crate::cmd::share::map_png(&mut s, Some(320.0), Some(200.0), None).is_ok()); s.active_layer = s.map().layer_by_name("scene").map(|l| l.id); s.run("raster.mask", &json!({"clear": true})).unwrap(); assert!(style(&s).mask.is_empty()); } /// Shards of `hostile_params_never_panic`: each takes every eighth command, so the parts run side by /// side (opengeos/RustGIS#1201). const HOSTILE_SHARDS: usize = 8; const _: () = assert!(HOSTILE_SHARDS == 8, "add a hostile_params_never_panic_N test for each shard"); #[test] fn hostile_params_never_panic_0() { hostile_params_never_panic(0); } #[test] fn hostile_params_never_panic_1() { hostile_params_never_panic(1); } #[test] fn hostile_params_never_panic_2() { hostile_params_never_panic(2); } #[test] fn hostile_params_never_panic_3() { hostile_params_never_panic(3); } #[test] fn hostile_params_never_panic_4() { hostile_params_never_panic(4); } #[test] fn hostile_params_never_panic_5() { hostile_params_never_panic(5); } #[test] fn hostile_params_never_panic_6() { hostile_params_never_panic(6); } #[test] fn hostile_params_never_panic_7() { hostile_params_never_panic(7); } /// Every command with junk parameters returns Ok or Err, never panics, and leaves a usable session. /// Runs the commands of one shard: those whose place in the registry is `shard` modulo /// [`HOSTILE_SHARDS`]. fn hostile_params_never_panic(shard: usize) { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test(&format!("tests::hostile_params_never_panic_{shard}")) { return; } let junk = [ Value::Null, json!({}), // Shared vertex moves: valid row/destination, hostile node addresses and coordinate types. json!({"layer":"Countries","row":0,"part":u64::MAX,"ring":-1,"index":[],"lon":12,"lat":45}), json!({"layer":"Countries","row":0,"part":0,"ring":0,"index":0,"lon":{},"lat":1e300}), json!({"layer": 99999, "path": "/nonexistent/x.geojson", "name": "", "where": "((", "tool": "buffer", "params": 7}), json!({"layer": "Countries", "row": 1e30, "field": "nope", "value": [1], "scale": -1, "lon": 1e300, "lat": "x", "classes": 1e9, "type": "graduated"}), // Label halos and spacing: out of range, of the wrong type. json!({"layer": "Cities", "haloWidth": 1e308, "haloColor": 7, "spacing": "NaN"}), json!({"layer": "Cities", "haloWidth": -1, "haloColor": "#zzz", "spacing": -1e9}), json!({"layer": "Cities", "rows": [1, 99999999], "geometry": {"type": "Polygon", "coordinates": [[]]}, "width": 1e9, "height": -3, "expression": "'"}), json!({"band": -1, "red": 1e12, "green": "x", "blue": f64::MAX, "preset": "", "ramp": "nope", "classes": -5, "n": 1e308, "low": -50, "high": 900, "gamma": 0, "zFactor": -1, "fileName": "a.tif", "data": "!!!"}), json!({"fileName": "a.tif", "data": "AAAA", "type": "unique", "on": "maybe"}), json!({"type": "custom", "min": 1e308, "max": -1e308}), json!({"type": "custom", "min": [1e308, "x", null, 4], "max": [null, -1e308, 5]}), json!({"type": "custom", "min": [1e308, null, 0], "max": [-1e308, f64::NAN, null], "red": 3}), json!({"kind": "colorbar", "ticks": -1e308, "tickSide": 7, "orientation": "x", "layer": "", "reverse": "maybe"}), json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), // A QGIS project and style whose web layer and scale range are junk. json!({"text": "type=xyz&url=&zmax=999wms"}), // A label class placed along lines, with junk the placement and style read after it. json!({"layer": "Countries", "position": "curved", "italic": "maybe", "size": -1e308}), // A line label's distance from its line: far out of range, then of the wrong type. json!({"layer": "Rivers", "position": "parallel", "lineOffset": -1e308}), json!({"layer": "Rivers", "lineOffset": {"above": 4}}), // A publication sheet's style, accent and orientation, and a shape's rounded corners. // (The profile left out or valid, so the style and accent are reached.) json!({"style": 7, "accent": "#XYZ", "kind": "ellipse", "element": 1, "radius": "x"}), json!({"style": "", "accent": [], "kind": "rectangle", "element": 1, "radius": -1e308}), json!({"profile": "a3", "style": "poster", "accent": "#ZZZZZZ", "kind": "rectangle", "radius": 1e308}), // A STAC trend's source: a valid band and period so the junk after them is reached, and an // API on a closed local port, so nothing is read. json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": "nowhere", "cellSize": -1e308, "resampling": 3, "start": "2020", "end": "2021"}), json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": [1e308, -91, 7, "x"], "start": "2020", "end": "2021"}), // A summary over time: a statistic given, so its threshold, groups and share are reached. json!({"stat": "countAbove", "above": "x", "by": 7, "minValid": -1e308, "months": [13, "x", null], "name": {"a": 1}}), json!({"stat": "std", "by": "month", "minValid": f64::NAN, "dates": ["x", 7, null]}), // A categorical summary: countIn's classes, and a mode by groups with a share out of range. json!({"stat": "countIn", "classes": [1e308, -1, "x", null, 3.5, {"a": 1}]}), json!({"stat": "countIn", "classes": "all", "by": "year"}), // A web-service connection's login method and secret, and the connection an add records. json!({"id": "c1", "kind": "wms", "auth": "query", "keyName": "\u{0}", "user": ":", "apiKey": "\n", "password": [], "allowInsecure": "yes", "connection": 7}), json!({"id": "c1", "auth": "basic", "user": 7, "keyName": {"a": 1}, "apiKey": "k".repeat(9000), "remember": "yes", "connection": "c999999"}), json!({"stat": "mode", "by": "month", "minValid": 2, "months": []}), json!({"stat": "frequency", "by": "year", "minValid": -1, "name": []}), // MySQL addresses that can't be read, so nothing connects. json!({"database": "mysql://u:p#@[::1/x?ssl-mode=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MARIADB://u@h:99999999/%zz?ssl-mode=VERIFY_CA&ssl-ca=/nonexistent", "sql": "SELECT 1"}), json!({"database": "mssql://u:p#@[::1/x?encrypt=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MSSQL://u@h:99999999/%zz?ca=/nonexistent", "sql": "SELECT 1"}), // Control points that don't parse, then ones that reach a fit. json!({"points": [[], 7, [1, 2, 3, 4, "x"]], "transform": "spline"}), json!({"points": [[0, 0, 1e308, -1e308], [1, 0, -1e308, 1e308], [0, 1, 1e308, 1e308], [1, 1, 0, 0, 0]], "transform": "spline"}), json!({"points": vec![json!([5, 5, 1, 1]); 1000], "compare": true}), // A warp: junk systems, sizes and paths with points that reach the fit. json!({"transform": "spline", "crs": "EPSG:999999999", "pointsCrs": [7], "resampling": 1, "cellSize": -1e308, "path": 5, "points": [[0, 0, 1, 1]]}), json!({"transform": "affine", "crs": 32613, "pointsCrs": 4326, "cellSize": 1e-300, "path": "/nonexistent/x.tif", "points": [[0, 0, 1, 1], [1, 0, 2, 1], [0, 1, 1, 2]]}), json!({"points": [[0, 0, f64::MAX, f64::MAX], [1, 0, -f64::MAX, f64::MAX], [0, 1, f64::MAX, -f64::MAX]], "compare": true}), // Polygons burned: a layer, a value and a no-data that pass, then a grid that can't be made. json!({"layer": "Countries", "value": 3, "nodata": -1e30, "cellSize": [1e-300, 1e300], "origin": [1e308, -1e308]}), json!({"layer": "Countries", "field": "name", "nodata": f64::MAX, "grid": [], "extent": "view", "path": 7}), // Connections: junk kinds, options and secrets; no connection exists in a fresh session. json!({"id": "c1", "kind": "s3", "address": "http://127.0.0.1:9", "name": "\u{7}", "group": 5, "options": {"credentials": "nope", "buckets": "NO_", "region": [1]}, "password": 7, "remember": [true], "accessKeyId": "A", "secretAccessKey": 9}), json!({"id": "", "kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 password=x", "options": "x"}), json!({"kind": "wms", "address": "http://127.0.0.1:9/wms", "probe": "x", "answer": 7}), json!({"id": {"unexpected":"object"}, "remember": "true", "password": []}), json!({"kind": "stac", "address": "http://127.0.0.1:9/stac", "answer": "\u{0}{"}), json!({"id": "c1", "node": "s3:/\u{7}", "probe": true, "answer": 7, "documents": "x"}), // Saved S3 keys: junk remember flags, partial pairs, tokens, and a choice of nothing. json!({"id": "c1", "accessKeyId": "A", "secretAccessKey": "S", "sessionToken": "T", "remember": "yes"}), json!({"id": "c1", "accessKeyId": 7, "secretAccessKey": [], "remember": null}), json!({"id": "c1", "remember": true, "source": "rustgis-s3:junk", "node": 5}), json!({"source": "rustgis-s3::0::s3://bucket/key", "id": "c1", "remember": {"a": 1}}), json!({"source": 7, "id": ["c1"]}), ]; let ids: Vec<&str> = Session::new(Project::default()) .registry .commands .iter() .enumerate() .filter(|(i, _)| i % HOSTILE_SHARDS == shard) .map(|(_, c)| c.id) .collect(); // Shards share the process under `cargo test`: each streams its own file. let streamed = grid_fgb(&format!("hostile-{shard}"), 20); for id in ids { if matches!( id, "project.save" | "project.saveAs" | "project.exportLog" | "share.exportMap" | "time.export" | "share.exportLayer" | "share.exportTable" | "share.exportRaster" // Download from the network. | "gp.installGeolibre" // Deletes the installed GeoLibre runner. | "gp.uninstallGeolibre" | "plugin.registry" | "plugin.installFromRegistry" // Ask Earth Search when no API is given. | "stac.collections" | "stac.search" // Asks geoBoundaries for its list. | "boundaries.countries" // Asks Source Cooperative for its featured products. | "sourcecoop.products" // Starts a sign-in's callback server (RustGIS's own app on ArcGIS Online). | "arcgis.signIn" // Signs every session out, which the process's other tests use (its junk is // covered by an_arcgis_token_in_a_service_address_reaches_its_layers). | "arcgis.signOut" ) { continue; } for p in &junk { // Places given by name go to the public geocoder: with an answer given, the junk // goes through reading it and the options, and nothing is fetched. let routed; let p = match p { Value::Object(o) if id == "map.route" => { let mut o = o.clone(); o.insert("answer".into(), json!(r#"{"code":"Ok","routes":[{"legs":[{"steps":[{"geometry":"_ibE_ibE_ibE_ibE","maneuver":{"type":"depart"}}]}]}]}"#)); routed = Value::Object(o); &routed } _ if id == "map.route" => continue, p => p, }; let mut s = world(); let _ = s.run(id, p); let _ = s.run("info.project", &Value::Null); // Again with a raster layer active. let mut s = with_raster(); let _ = s.run(id, p); let _ = s.run("info.project", &Value::Null); // And with a streamed layer active. let mut s = with_streamed(&streamed); let _ = s.run(id, p); let _ = s.run("info.project", &Value::Null); } } } #[test] fn every_tool_survives_an_environment_extent() { // An extent holding some features, and one holding none, with every tool and junk params. for extent in [json!([0, 40, 20, 55]), json!([-179.9, -89, -179.8, -88.9])] { for t in rustgis_analysis::tools().filter(|t| !rustgis_analysis::is_external(t)) { let mut s = world(); s.run("gp.env", &json!({"extent": extent})).unwrap(); for params in [json!({"input": "Countries"}), json!({"input": "Cities", "distance": 1e9, "clip": "Countries"})] { let _ = s.run("gp.run", &json!({"tool": t.id, "params": params})); } let _ = s.run("info.project", &Value::Null); } } let mut s = world(); s.run("gp.env", &json!({"extent": [-179.9, -89, -179.8, -88.9]})).unwrap(); let e = s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 10}})).unwrap_err(); assert!(e.contains("inside the environment"), "{e}"); } #[cfg(feature = "geolibre")] #[test] fn geolibre_tools_run_on_layers_and_add_their_outputs() { let mut s = with_raster(); let st = s.run("gp.geolibre", &Value::Null).unwrap(); assert_eq!(st["supported"], true); assert!(s.run("gp.setGeolibre", &json!({"path": "/no/such/geolibre"})).is_err(), "not a runner"); let undo = s.undo_label(); s.run("gp.setGeolibre", &json!({"path": ""})).unwrap(); assert_eq!(s.undo_label(), undo, "choosing a runner isn't an edit"); if st["available"] != true { eprintln!("skipped: no GeoLibre runner (set RUSTGIS_GEOLIBRE)"); return; } let tools = s.run("gp.tools", &Value::Null).unwrap(); assert!(tools.as_array().unwrap().iter().any(|t| t["id"] == "geolibre:slope")); let before = s.map().layers.len(); let r = s.run("gp.run", &json!({"tool": "geolibre:slope", "params": {"input": "scene"}})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(1), "{r}"); assert_eq!(s.map().layers.len(), before + 1); let slope = s.map().layer_by_name("Slope").unwrap(); assert!(slope.is_raster() && slope.raster.as_ref().is_some_and(|r| r.embedded.is_some()), "kept in the project"); // It saves with the project, and undo takes it away. let json = serde_json::to_string(&s.project).unwrap(); assert!( Project::from_json(&json) .unwrap() .map() .layer_by_name("Slope") .is_some_and(|l| l.raster.as_ref().is_some_and(|r| r.data.is_some())) ); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layer_by_name("Slope").is_none()); // A vector where a raster belongs is an error, not a crash. assert!(s.run("gp.run", &json!({"tool": "geolibre:slope", "params": {"input": "Cities"}})).is_err()); } /// The browser's way: prepare a run, let a runner (here, pretend) produce the files, then /// apply it with `gp.finish`, which keeps undo and history. #[cfg(feature = "geolibre")] #[test] fn geolibre_runs_can_finish_asynchronously() { use crate::cmd::gp::{external_complete, external_start}; let mut s = with_raster(); assert!(s.run("gp.finish", &json!({"id": 99})).is_err(), "nothing pending"); assert!(external_complete(&mut s, 99, Ok((vec![], vec![]))).is_err(), "nothing started"); if s.run("gp.geolibre", &Value::Null).unwrap()["available"] != true { eprintln!("skipped: no GeoLibre runner (set RUSTGIS_GEOLIBRE)"); return; } assert!(external_start(&mut s, "geolibre:slope", &json!({})).err().unwrap().contains("required")); assert!(external_start(&mut s, "buffer", &json!({})).is_err(), "only GeoLibre tools"); let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); assert_eq!((run.tool.as_str(), run.files.len()), ("slope", 1), "the embedded raster is staged"); assert!(run.argv.iter().any(|a| a.starts_with("--output=/work/"))); // The "runner" writes the input back as the output. let out = run.files[0].1.clone(); external_complete(&mut s, run.id, Ok((vec![("output.tif".into(), out)], vec![]))).unwrap(); let before = s.map().layers.len(); let r = s.run("gp.finish", &json!({"id": run.id})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(1), "{r}"); assert_eq!(s.map().layers.len(), before + 1); assert!(s.project.gp_history.last().is_some_and(|h| h.ok && h.tool == "Slope")); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layers.len(), before, "undo removes it"); assert!(s.run("gp.finish", &json!({"id": run.id})).is_err(), "applied once"); // A runner error is reported like a failed gp.run. let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); external_complete(&mut s, run.id, Err("trap".into())).unwrap(); assert_eq!(s.run("gp.finish", &json!({"id": run.id})).unwrap_err(), "Slope: trap"); // A run of a project that was closed meanwhile doesn't land in the new one. let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); s.run("project.new", &Value::Null).unwrap(); assert!(external_complete(&mut s, run.id, Ok((vec![], vec![]))).unwrap_err().contains("project was closed")); } #[test] fn clipboard_copies_cuts_and_pastes_features() { let mut s = world(); assert!(s.run("edit.copy", &Value::Null).is_err(), "nothing selected"); s.run("select.byAttributes", &json!({"layer": "Cities", "where": "name = 'Paris'"})).unwrap(); let r = s.run("edit.copy", &Value::Null).unwrap(); assert_eq!(r["copied"], 1); let text = r["text"].as_str().unwrap().to_string(); assert!(text.contains("Paris")); // Into the same layer, then as a new layer, then from GeoJSON text. let n = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); s.run("edit.paste", &json!({"layer": "Cities"})).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), n + 1); assert!(s.run("edit.paste", &json!({"layer": "Countries"})).is_err(), "points don't go in polygons"); let r = s.run("edit.pasteSpecial", &json!({"name": "Copied"})).unwrap(); assert_eq!(r["pasted"], 1); assert_eq!(s.map().layer_by_name("Copied").unwrap().dataset.features.len(), 1); s.run("edit.pasteSpecial", &json!({"text": text, "name": "FromText"})).unwrap(); assert!(s.map().layer_by_name("FromText").is_some()); assert!(s.run("edit.paste", &json!({"layer": "Cities", "text": "not json"})).is_err()); // Cut removes the features. s.run("select.byAttributes", &json!({"layer": "Cities", "where": "name = 'Paris'"})).unwrap(); let cut = s.run("edit.cut", &Value::Null).unwrap(); assert_eq!(cut["copied"], 2); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), n - 1); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), n + 1); // The sample layers live in the project, not in files. assert!(s.run("layer.copyPath", &json!({"layer": "Cities"})).is_err()); } #[test] fn popup_modes_choose_what_identify_reports() { let mut s = world(); let at = json!({"lon": 2.35, "lat": 48.85, "tolerancePx": 5}); let top = s.run("identify", &at).unwrap(); assert_eq!(top["layer"], "Cities"); s.run("map.popupMode", &json!({"mode": "visible"})).unwrap(); let all = s.run("identify", &at).unwrap(); let layers: Vec<&str> = all["results"].as_array().unwrap().iter().map(|r| r["layer"].as_str().unwrap()).collect(); assert_eq!(layers, ["Cities", "Countries"]); s.run("map.popupMode", &json!({"mode": "selectedInContents"})).unwrap(); s.run("layer.select", &json!({"layer": "Countries"})).unwrap(); assert_eq!(s.run("identify", &at).unwrap()["layer"], "Countries"); s.run("map.popupMode", &json!({"mode": "none"})).unwrap(); assert!(s.run("identify", &at).unwrap().is_null()); assert!(s.run("map.popupMode", &json!({"mode": "bogus"})).is_err()); // The mode is saved with the project. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("\"popup_mode\":\"None\"")); } #[test] fn identify_reports_every_overlapping_feature() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = world(); // Two buoys on one spot of the South Atlantic, far from any country, under a float. let spot = || Some(Geometry::Point(rustgis_geom::Coord::new(-20.0, -30.0))); let mut d = Dataset::new("Buoys", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); for n in [1, 2] { d.push(spot(), vec![V::Int(n)]); } let id = u64::from(s.map_mut().add_dataset(d)); let mut d = Dataset::new("Floats", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); d.push(spot(), vec![V::Int(1)]); let float = u64::from(s.map_mut().add_dataset(d)); let at = json!({"lon": -20.0, "lat": -30.0, "tolerancePx": 5}); let rows = |v: &Value| { v["results"].as_array().unwrap().iter().map(|r| (r["layerId"].as_u64().unwrap(), r["row"].as_u64().unwrap())).collect::>() }; // Topmost: every feature of the top layer with one; both buoys, the one drawn on top first (#232). assert_eq!(rows(&s.run("identify", &at).unwrap()), [(float, 0)]); s.run("layer.visibility", &json!({"layer": "Floats", "visible": false})).unwrap(); let top = s.run("identify", &at).unwrap(); assert_eq!(rows(&top), [(id, 1), (id, 0)]); assert_eq!(top["row"], 1); // Each result carries its feature id, to find the feature again after a streamed read. assert_eq!(top["results"][0]["fid"], 2); assert_eq!(top["results"][1]["fid"], 1); s.run("layer.visibility", &json!({"layer": "Floats", "visible": true})).unwrap(); s.run("map.popupMode", &json!({"mode": "visible"})).unwrap(); assert_eq!(rows(&s.run("identify", &at).unwrap()), [(float, 0), (id, 1), (id, 0)]); // Selectable: every selectable layer, not only the top one. s.run("map.popupMode", &json!({"mode": "selectable"})).unwrap(); assert_eq!(rows(&s.run("identify", &at).unwrap()), [(float, 0), (id, 1), (id, 0)]); s.run("layer.selectable", &json!({"layer": "Floats", "on": false})).unwrap(); assert_eq!(rows(&s.run("identify", &at).unwrap()), [(id, 1), (id, 0)]); // A definition query hides one. s.run("layer.definitionQuery", &json!({"layer": "Buoys", "where": "n = 1"})).unwrap(); assert_eq!(rows(&s.run("identify", &at).unwrap()), [(id, 0)]); } #[test] fn label_size_stays_within_what_text_symbol_shows() { // Labeling › Text Symbol offers the range `labels.style` accepts (#457): 72 pt is kept, and // anything outside is brought back to the nearest end. let mut s = world(); let size = |s: &Session| s.map().layers.iter().find(|l| l.name == "Countries").map(|l| f64::from(l.labels.default.size)); for (given, kept) in [(72.0, 72.0), (100.0, 72.0), (1.0, 4.0)] { s.run("labels.style", &json!({"layer": "Countries", "size": given})).unwrap(); assert_eq!(size(&s), Some(kept), "size {given}"); } let range = rustgis_map::symbology::LABEL_SIZE; assert_eq!((*range.start(), *range.end()), (4.0, 72.0)); } #[test] fn label_range_placement_lock_and_convert() { use crate::render::View; let mut s = world(); s.run("labels.enable", &json!({"layer": "Countries", "on": true})).unwrap(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 20_000_000})).unwrap(); let view = |s: &Session| View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let count = |s: &mut Session| { let v = view(s); crate::render::render(s.map_mut(), &v).unwrap().labels.len() }; let placed = count(&mut s); assert!(placed > 5); // Out beyond 1:5,000,000: no labels at 1:20,000,000. s.run("labels.scaleRange", &json!({"layer": "Countries", "min": 5_000_000})).unwrap(); let n = count(&mut s); assert!(n < placed, "{n} < {placed}"); s.run("labels.scaleRange", &json!({"layer": "Countries", "clear": true})).unwrap(); assert_eq!(count(&mut s), placed); s.run("labels.distanceRange", &json!({"layer": "Countries", "farthest": 1000})).unwrap(); assert!(count(&mut s) < placed); s.run("labels.distanceRange", &json!({"layer": "Countries", "farthest": null})).unwrap(); assert!(s.run("labels.scaleRange", &json!({"layer": "Countries", "min": -4})).is_err()); // Pause hides every label; unplaced labels are reported when asked for. s.run("labels.pause", &json!({"on": true})).unwrap(); assert_eq!(count(&mut s), 0); s.run("labels.pause", &json!({"on": false})).unwrap(); s.run("labels.viewUnplaced", &json!({"on": true})).unwrap(); let v = view(&s); let r = crate::render::render(s.map_mut(), &v).unwrap(); assert!(!r.unplaced.is_empty() && r.unplaced.iter().all(|l| l.color == crate::render::UNPLACED)); s.run("labels.viewUnplaced", &json!({"on": false})).unwrap(); // Placement moves labels. s.run("labels.placement", &json!({"layer": "Cities", "position": "right"})).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().labels.default.placement, crate::map::LabelPlacement::Right); assert!(s.run("labels.placement", &json!({"position": "sideways"})).is_err()); // Lock keeps the labelled set as the map moves. s.run("labels.lock", &json!({"on": true})).unwrap(); let locked = count(&mut s); assert!(s.map().label_lock.is_some()); s.run("map.zoomOut", &Value::Null).unwrap(); assert!(count(&mut s) <= locked); s.run("labels.lock", &json!({"on": false})).unwrap(); count(&mut s); assert!(s.map().label_lock.is_none()); // Convert makes an annotation layer and turns the source's labels off; its labels stay on // their points, even when the source's classes had the other places on (#1011: refused for // polygons by labels.placement, but a project file may have it). s.run("labels.class.add", &json!({"layer": "Countries", "name": "Big", "where": "pop_est > 100000000"})).unwrap(); let countries = s.map().layer_by_name("Countries").unwrap().id; let labels = &mut s.map_mut().layer_mut(countries).unwrap().labels; labels.default.other_positions = true; labels.classes.iter_mut().for_each(|c| c.other_positions = true); let r = s.run("labels.convert", &json!({"layer": "Countries"})).unwrap(); assert!(r["annotations"].as_u64().unwrap() > 100); let a = s.map().layer_by_name("Countries Annotation").unwrap(); assert!(a.labels.enabled && a.labels.default.field == "TEXT"); assert!(!a.labels.default.other_positions && !a.labels.classes.is_empty()); assert!(a.labels.classes.iter().all(|c| !c.other_positions), "every class's, not only Default's"); assert!(!s.map().layer_by_name("Countries").unwrap().labels.enabled); } /// A label class's font is set by its family name, installed here or not (the project may be /// drawn on a machine that has it), and cleared with a blank one; `labels.fonts` lists the /// installed families. #[test] fn labels_take_a_font_by_family_name() { let mut s = world(); let font = |s: &Session| s.map().layer_by_name("Cities").unwrap().labels.default.font.clone(); s.run("labels.style", &json!({"layer": "Cities", "font": " Liberation Sans Narrow "})).unwrap(); assert_eq!(font(&s).as_deref(), Some("Liberation Sans Narrow")); // Drawn in the label font where it isn't installed, without fail. s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); let v = crate::render::View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap(); s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); assert!(font(&s).is_some(), "other settings leave it"); s.run("labels.style", &json!({"layer": "Cities", "font": ""})).unwrap(); assert_eq!(font(&s), None); s.run("edit.undo", &json!({})).unwrap(); assert!(font(&s).is_some(), "undoes"); let listed = s.run("labels.fonts", &json!({"search": "zzz-no-such-family"})).unwrap(); assert_eq!(listed["fonts"], json!([])); assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array()); } /// A streamed window cannot discard saved moves for off-window features to make space at the cap. #[test] fn moved_labels_preserve_off_window_features_at_the_cap() { use rustgis_map::symbology::{MAX_MOVED_LABELS, MovedLabel}; let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; let layer = s.map_mut().layer_mut(id).unwrap(); layer.dataset.windowed = true; layer.labels.default.moved = (0..MAX_MOVED_LABELS).map(|i| MovedLabel { id: 1_000_000 + i as u64, at: [1.0, 1.0] }).collect(); let error = s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap_err(); assert!(error.contains("at most"), "{error}"); assert_eq!(s.map().layer(id).unwrap().labels.default.moved.len(), MAX_MOVED_LABELS); let error = s.run("labels.move", &json!({"layer": id, "feature": u64::MAX, "to": [2, 2]})).unwrap_err(); assert!(error.contains("not present"), "{error}"); } /// A class at its most drops the saved moves of features no longer in the layer to make room, and /// says how many; a class with room drops none and says nothing. #[test] fn moved_labels_of_removed_features_make_room_with_a_notice() { use rustgis_map::symbology::{MAX_MOVED_LABELS, MovedLabel}; let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; let layer = s.map_mut().layer_mut(id).unwrap(); layer.labels.default.moved = (0..MAX_MOVED_LABELS).map(|i| MovedLabel { id: 1_000_000 + i as u64, at: [1.0, 1.0] }).collect(); let mark = s.notice_mark(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap(); assert_eq!(s.map().layer(id).unwrap().labels.default.moved.len(), 1); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("10000 saved label move(s) of features no longer in the layer"))); let mark = s.notice_mark(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [3, 3]})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("dropped to make room"))); } /// A label moved where the map's projection can't draw it is kept, and said not to be drawn; /// one moved inside it gets no notice. #[test] fn a_label_moved_outside_the_projection_says_so() { let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; s.run("map.projection", &json!({"type": "usaAlbers"})).unwrap(); let mark = s.notice_mark(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [-100.0, 40.0]})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("isn't drawn")), "inside: no notice"); let mark = s.notice_mark(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [12.5, 41.9]})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("outside the area the map's projection"))); assert_eq!(s.map().layer(id).unwrap().labels.default.moved.len(), 1, "kept"); } /// Labels are moved by hand by feature id and put back, one class's or all; bad input is refused. #[test] fn labels_are_moved_by_command_and_reset() { let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let fid = s.map().layer(id).unwrap().dataset.features.first().unwrap().id; let moved = |s: &Session| s.map().layer(id).unwrap().labels.default.moved.clone(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [12.5, 41.9]})).unwrap(); assert_eq!(moved(&s), vec![rustgis_map::symbology::MovedLabel { id: fid, at: [12.5, 41.9] }]); // By the class's name too (Default's); again moves it again. let name = s.map().layer(id).unwrap().labels.default.name.clone(); s.run("labels.move", &json!({"layer": id, "class": name, "feature": fid, "to": [13.0, 42.0]})).unwrap(); assert_eq!(moved(&s).len(), 1); // Bad values change nothing. for bad in [ json!({"feature": fid}), json!({"feature": "one", "to": [1, 1]}), json!({"feature": fid, "to": [1]}), json!({"feature": fid, "to": [1, 200]}), json!({"feature": fid, "to": ["a", 1]}), json!({"feature": fid, "to": [1, 1], "class": "No such class"}), ] { let mut p = bad.clone(); p["layer"] = json!(id); assert!(s.run("labels.move", &p).is_err(), "{bad}"); } assert_eq!(moved(&s)[0].at, [13.0, 42.0]); // Saved with the project's layer, and one undo step a move. let saved = serde_json::to_value(&s.map().layer(id).unwrap().labels).unwrap(); assert_eq!(saved["moved"], json!([{"id": fid, "at": [13.0, 42.0]}])); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(moved(&s)[0].at, [12.5, 41.9]); // Put back: one, then all. s.run("labels.move", &json!({"layer": id, "feature": fid, "to": null})).unwrap(); assert!(moved(&s).is_empty()); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [1, 1]})).unwrap(); // A bad "all" is refused, not taken as false. assert!(s.run("labels.resetMoved", &json!({"layer": id, "all": "tru"})).is_err()); assert_eq!(moved(&s).len(), 1); s.run("labels.class.add", &json!({"layer": id, "name": "Named"})).unwrap(); s.run("labels.move", &json!({"layer": id, "class": "Named", "feature": fid, "to": [3, 3]})).unwrap(); s.run("labels.resetMoved", &json!({"layer": id, "all": true})).unwrap(); assert!(moved(&s).is_empty()); assert!(s.map().layer(id).unwrap().labels.class("Named").unwrap().moved.is_empty(), "every class's"); s.run("labels.class.remove", &json!({"layer": id, "class": "Named"})).unwrap(); // Locked, a move locks the labels again where they are placed next: the one moved may not // have been placed when the lock was taken. s.run("labels.lock", &json!({"on": true})).unwrap(); let v = crate::render::View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap(); assert!(s.map().label_lock.is_some()); // With nothing moved, Reset Moved changes nothing and the lock holds. s.run("labels.resetMoved", &json!({"layer": id, "all": true})).unwrap(); assert!(s.map().label_lock.is_some(), "a reset with nothing moved keeps the lock"); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap(); assert!(s.map().label_lock.is_none() && s.map().label_display.locked, "locked again, not unlocked"); // A move to where the label already is changes nothing either. crate::render::render(s.map_mut(), &v).unwrap(); s.run("labels.move", &json!({"layer": id, "feature": fid, "to": [2, 2]})).unwrap(); assert!(s.map().label_lock.is_some(), "a move that changes nothing keeps the lock"); // Converted to annotations, a moved label's annotation is where it was put, and the // annotations' classes keep none moved (their ids aren't the features'). s.run("labels.lock", &json!({"on": false})).unwrap(); let r = s.run("labels.convert", &json!({"layer": id})).unwrap(); let a = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); // It stays moved there under its new id, so its class's offset doesn't shift it. let at = |g: &Option| match g { Some(rustgis_data::Geometry::Point(c)) => Some([c.x, c.y]), _ => None, }; let put = a.dataset.features.iter().find(|f| at(&f.geometry) == Some([2.0, 2.0])).expect("the moved one's annotation"); assert_eq!(a.labels.default.moved, vec![rustgis_map::symbology::MovedLabel { id: put.id, at: [2.0, 2.0] }]); // The tool by its code. s.run("tool.set", &json!({"tool": "moveLabel"})).unwrap(); assert_eq!(s.tool, crate::Tool::MoveLabel); } #[test] fn labels_show_all_and_take_callouts() { // A project file's 0 is off, as the command's is; a length is kept. let ds = rustgis_data::Dataset::new("d", rustgis_data::GeometryType::Point, vec![]); let mut v = serde_json::to_value(rustgis_map::LabelClass::default_for(&ds)).unwrap(); for (file, read) in [(json!(0), None), (json!(-3), None), (json!(30), Some(30.0))] { v["callout"] = file; assert_eq!(serde_json::from_value::(v.clone()).unwrap().callout, read); } let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Cities").unwrap().labels.default; (c.show_all, c.callout) }; assert_eq!(class(&s), (false, None)); s.run("labels.placement", &json!({"layer": "Cities", "showAll": true})).unwrap(); assert_eq!(class(&s), (true, None), "no position needed"); // On: the default length, then one given, kept within range; on again keeps it. s.run("labels.placement", &json!({"layer": "Cities", "callout": true})).unwrap(); assert_eq!(class(&s).1, Some(rustgis_map::symbology::DEFAULT_CALLOUT)); let adjusted = |s: &Session, mark| s.notices_since(mark).iter().filter(|n| n.text.contains("callout length")).count(); let mark = s.notice_mark(); s.run("labels.placement", &json!({"layer": "Cities", "callout": 80})).unwrap(); assert_eq!(class(&s).1, Some(80.0)); assert_eq!(adjusted(&s, mark), 0, "a length in range says nothing"); s.run("labels.placement", &json!({"layer": "Cities", "callout": 99999})).unwrap(); assert_eq!(class(&s).1, Some(500.0), "within its range"); assert_eq!(adjusted(&s, mark), 1, "and says it was adjusted"); s.run("labels.placement", &json!({"layer": "Cities", "callout": true})).unwrap(); assert_eq!(class(&s).1, Some(500.0), "on again keeps its length"); // Saved with the layer, and read back. let saved = serde_json::to_value(&s.map().layer_by_name("Cities").unwrap().labels).unwrap(); assert_eq!((saved["show_all"].clone(), saved["callout"].clone()), (json!(true), json!(500.0))); let back: rustgis_map::symbology::Labeling = serde_json::from_value(saved).unwrap(); assert_eq!((back.default.show_all, back.default.callout), (true, Some(500.0))); // A project file's out-of-range length is brought within range. let mut wild = serde_json::to_value(&back).unwrap(); wild["callout"] = json!(1e9); let read: rustgis_map::symbology::Labeling = serde_json::from_value(wild).unwrap(); assert_eq!(read.default.callout, Some(500.0)); // Off, three ways; bad values change nothing. for off in [json!(false), json!(0), json!(null)] { s.run("labels.placement", &json!({"layer": "Cities", "callout": 40})).unwrap(); s.run("labels.placement", &json!({"layer": "Cities", "callout": off})).unwrap(); assert_eq!(class(&s).1, None, "{off}"); } for bad in [json!({"callout": "long"}), json!({"callout": -5}), json!({"showAll": "yes"})] { let mut p = bad.clone(); p["layer"] = json!("Cities"); assert!(s.run("labels.placement", &p).is_err(), "{bad}"); } s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s), (true, Some(40.0)), "undoes"); } /// A label class's halo takes its own colour and width, and its letters a spacing; blank and null /// go back to the automatic colour and the thin halo, and other settings leave them. #[test] fn labels_take_a_halo_colour_and_width_and_letter_spacing() { use rustgis_map::Rgba; let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Cities").unwrap().labels.default; (c.halo_color, c.halo_width, c.spacing) }; assert_eq!(class(&s), (None, None, 0.0)); let mark = s.notice_mark(); s.run("labels.style", &json!({"layer": "Cities", "haloColor": "#FFEE00", "haloWidth": 1.42, "spacing": "0.5"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5)); s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5), "other settings leave them"); s.run("labels.style", &json!({"layer": "Cities", "haloWidth": 99, "spacing": -99})).unwrap(); assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "within their ranges"); s.run("labels.style", &json!({"layer": "Cities", "haloColor": "", "haloWidth": null})).unwrap(); assert_eq!(class(&s), (None, None, -5.0)); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "undoes"); s.run("labels.style", &json!({"layer": "Cities", "haloColor": null})).unwrap(); assert_eq!(class(&s).0, None); for bad in [ json!({"haloColor": "pink"}), json!({"haloColor": 7}), json!({"haloWidth": "wide"}), json!({"spacing": null}), json!({"spacing": [1]}), ] { let mut p = bad.clone(); p["layer"] = json!("Cities"); assert!(s.run("labels.style", &p).is_err(), "{bad}"); } } /// A label class takes wrapping and its lines' alignment with its text symbol, and a repeat along /// lines with its placement; junk is refused, numbers kept within their ranges, each change undoes /// and the project keeps them. #[test] fn labels_take_wrapping_and_a_repeat_along_lines() { use rustgis_map::layout::TextAlign; let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; (c.wrap_char.clone(), c.wrap_length, c.line_align, c.repeat, c.placement) }; let placement = class(&s).4; assert_eq!(class(&s), (None, 0, TextAlign::Left, None, placement)); let mark = s.notice_mark(); s.run("labels.style", &json!({"layer": "Rivers", "wrapChar": " ", "wrapLength": "12", "lineAlign": "CENTER"})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 144})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); assert_eq!(class(&s), (Some(" ".into()), 12, TextAlign::Center, Some(144.0), placement), "a repeat alone keeps the position"); // Within their ranges; none again. s.run("labels.style", &json!({"layer": "Rivers", "wrapChar": "abcdefghijk", "wrapLength": 1e9})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 0.5})).unwrap(); let c = class(&s); assert_eq!((c.0.as_deref(), c.1, c.3), (Some("abcdefgh"), rustgis_map::symbology::MAX_WRAP_LENGTH, Some(10.0))); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 1e9})).unwrap(); assert_eq!(class(&s).3, Some(10_000.0)); s.run("labels.style", &json!({"layer": "Rivers", "wrapChar": "", "wrapLength": 0})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": null})).unwrap(); assert_eq!((class(&s).0, class(&s).1, class(&s).3), (None, 0, None)); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s).3, Some(10_000.0), "undoes"); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 0})).unwrap(); assert_eq!(class(&s).3, None, "0 is none"); for bad in [ json!({"wrapChar": 7}), json!({"wrapLength": -3}), json!({"wrapLength": "long"}), json!({"lineAlign": "justify"}), json!({"lineAlign": 1}), ] { let mut p = bad.clone(); p["layer"] = json!("Rivers"); assert!(s.run("labels.style", &p).is_err(), "{bad}"); } for bad in [json!({"repeat": -5}), json!({"repeat": "often"}), json!({"repeat": [1]})] { let mut p = bad.clone(); p["layer"] = json!("Rivers"); assert!(s.run("labels.placement", &p).is_err(), "{bad}"); } // Kept with the project. s.run("labels.style", &json!({"layer": "Rivers", "wrapChar": "/", "lineAlign": "right"})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "repeat": 200})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!(class(&reopened), class(&s)); } /// A line's label class takes a distance from the line (#1187): alone it keeps the position, it is /// held to its range, 0 and null put the label back on the line, junk is refused, it undoes and the /// project keeps it. #[test] fn labels_take_a_distance_from_the_line() { let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; (c.line_offset, c.placement) }; let placement = class(&s).1; assert_eq!(class(&s), (0.0, placement)); let mark = s.notice_mark(); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": 4})).unwrap(); assert_eq!(class(&s), (4.0, placement), "a distance alone keeps the position"); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": "-2.5"})).unwrap(); assert_eq!(class(&s).0, -2.5, "below the line"); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": 200})).unwrap(); assert!(s.notices_since(mark).is_empty(), "a distance within its range says nothing: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": 1e9})).unwrap(); assert_eq!(class(&s).0, 200.0, "within its range"); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter().any(|t| t == "Rivers: lineOffset 1e9 points adjusted to 200 (a label sits at most 200 points above or below its line)"), "and says where, in short: {said:?}" ); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": null})).unwrap(); assert_eq!(class(&s).0, 0.0, "null is on the line"); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s).0, 200.0, "undoes"); s.run("labels.placement", &json!({"layer": "Rivers", "lineOffset": 0})).unwrap(); assert_eq!(class(&s).0, 0.0, "0 is on the line"); for bad in [json!({"lineOffset": "above"}), json!({"lineOffset": [1]}), json!({"lineOffset": true})] { let mut p = bad.clone(); p["layer"] = json!("Rivers"); assert!(s.run("labels.placement", &p).is_err(), "{bad}"); } // Kept with the project. s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "lineOffset": -6})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!(class(&reopened), (-6.0, rustgis_map::LabelPlacement::Curved)); } /// A project whose label distance from the line is out of range or not a number says so on /// opening, one notice with a count; in range, 0 or null says nothing (#1187). #[test] fn opening_a_project_says_when_a_line_label_distance_is_adjusted() { let said = |v: &Value| { let s = Session::new(Project::from_json(&v.to_string()).unwrap()); s.notices.iter().filter(|n| n.text.contains("label distance(s) from the line")).map(|n| n.text.clone()).collect::>() }; let clean: Value = serde_json::to_value(&world().project).unwrap(); assert!(said(&clean).is_empty()); for fine in [json!(4.5), json!(-200), json!(0), json!(null)] { let mut v = clean.clone(); v["maps"][0]["layers"][0]["labels"]["line_offset"] = fine.clone(); assert!(said(&v).is_empty(), "{fine}"); } let mut damaged = clean.clone(); damaged["maps"][0]["layers"][0]["labels"]["line_offset"] = json!(900); damaged["maps"][0]["layers"][1]["labels"]["line_offset"] = json!("above"); let loaded = Project::from_json(&damaged.to_string()).unwrap(); assert_eq!(loaded.maps[0].layers[0].labels.default.line_offset, 200.0); assert_eq!(loaded.maps[0].layers[1].labels.default.line_offset, 0.0); let notes = said(&damaged); assert_eq!(notes.len(), 1, "{notes:?}"); assert!( notes[0].ends_with("2 label distance(s) from the line out of range or not a number adjusted while opening the project"), "{notes:?}" ); } /// Labels along lines converted to annotation become upright points on their lines, and the /// conversion says how many lost their angle, curve and distance from the line; points' labels /// lose none of that, and say nothing (#1187). #[test] fn converting_labels_along_lines_says_what_they_lose() { let mut s = world(); s.run("labels.enable", &json!({"layer": "Rivers", "on": true})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "position": "parallel", "lineOffset": 6})).unwrap(); let mark = s.notice_mark(); let n = s.run("labels.convert", &json!({"layer": "Rivers"})).unwrap()["annotations"].as_u64().unwrap(); assert!(n > 0); let said: Vec = s.notices_since(mark).iter().map(|x| x.text.clone()).collect(); let expected = format!( "Rivers Annotation: {n} label(s) along lines made upright points at their lines' middle vertex, no longer laid along the line" ); assert!(said.contains(&expected), "{said:?}"); assert!(said.iter().all(|t| !t.contains("repeats")), "none repeated: {said:?}"); // The annotation's labels aren't along a line, so they keep no distance from one. let annotation = s.map().layer_by_name("Rivers Annotation").unwrap(); assert_eq!(annotation.labels.default.line_offset, 0.0); // Repeated along their lines, each converts once (as many annotations), and says so. s.undo(); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 144})).unwrap(); let mark = s.notice_mark(); let repeated = s.run("labels.convert", &json!({"layer": "Rivers"})).unwrap()["annotations"].as_u64().unwrap(); assert_eq!(repeated, n, "one annotation a label, however many repeats it drew"); let said: Vec = s.notices_since(mark).iter().map(|x| x.text.clone()).collect(); assert!(said.contains(&format!("Rivers Annotation: {n} label(s) whose class repeats along lines converted once each")), "{said:?}"); let mark = s.notice_mark(); s.run("labels.convert", &json!({"layer": "Cities"})).unwrap(); assert!(s.notices_since(mark).iter().all(|x| !x.text.contains("along lines")), "{:?}", s.notices_since(mark)); } /// A point label class tries the other places round its point when told to (#1011): alone it keeps /// the position, junk is refused (in it or in the position) and so is turning it on for lines at a /// position off them and for polygons, it undoes, the project keeps it, and turning it on or off /// while the labels are locked locks them again (the places they took no longer stand). #[test] fn labels_take_other_positions_round_points() { use crate::render::View; let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Cities").unwrap().labels.default; (c.other_positions, c.placement) }; let placement = class(&s).1; assert_eq!(class(&s), (false, placement)); s.run("labels.placement", &json!({"layer": "Cities", "otherPositions": true})).unwrap(); assert_eq!(class(&s), (true, placement), "alone it keeps the position"); s.run("edit.undo", &json!({})).unwrap(); assert!(!class(&s).0, "undoes"); for bad in [json!("yes"), json!(1), Value::Null] { assert!(s.run("labels.placement", &json!({"layer": "Cities", "otherPositions": bad})).is_err(), "{bad}"); } // A position that isn't a placement name is refused, not dropped for otherPositions; null is // none. for bad in [json!(123), json!(true), json!(["above"])] { assert!(s.run("labels.placement", &json!({"layer": "Cities", "position": bad, "otherPositions": true})).is_err(), "{bad}"); } assert!(!class(&s).0); s.run("labels.placement", &json!({"layer": "Cities", "position": null, "otherPositions": false})).unwrap(); assert_eq!(class(&s), (false, placement), "null is no position"); // Only a point's label goes round it: on is refused for a line off its line (along it, see // labels_take_other_positions_along_lines) and for polygons, off taken anywhere. for layer in ["Rivers", "Countries"] { assert!(s.run("labels.placement", &json!({"layer": layer, "otherPositions": true})).is_err(), "{layer}"); s.run("labels.placement", &json!({"layer": layer, "otherPositions": false})).unwrap(); } s.run("labels.placement", &json!({"layer": "Cities", "otherPositions": true})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); assert!(Session::new(serde_json::from_str(&text).unwrap()).map().layer_by_name("Cities").unwrap().labels.default.other_positions); s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); s.run("labels.lock", &json!({"on": true})).unwrap(); let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap(); assert!(s.map().label_lock.is_some()); s.run("labels.placement", &json!({"layer": "Cities", "otherPositions": false})).unwrap(); assert!(s.map().label_lock.is_none(), "turned off: locked again"); s.run("labels.lock", &json!({"on": true})).unwrap(); crate::render::render(s.map_mut(), &v).unwrap(); assert!(s.map().label_lock.is_some()); s.run("labels.placement", &json!({"layer": "Cities", "otherPositions": true})).unwrap(); assert!(s.map().label_lock.is_none(), "turned on: locked again"); } /// A line label class tries other places along its line when told to (#1187): only placed parallel /// or curved and without a repeat, judged on what the call leaves, in the class it names. A refusal /// changes nothing, a later position or repeat keeps the flag, it undoes, the project keeps it, and /// turning it on or off locks the labels again. #[test] fn labels_take_other_positions_along_lines() { use rustgis_map::LabelPlacement::{Above, Auto, Curved, Parallel}; let mut s = world(); let id = s.map().layer_by_name("Rivers").unwrap().id; let class = |s: &Session| { let c = &s.map().layer(id).unwrap().labels.default; (c.other_positions, c.placement, c.repeat) }; assert_eq!(class(&s), (false, Auto, None)); // Not at a position off the line, nor with a repeat: each refusal leaves all of the call undone. assert!(s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": true})).is_err()); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "position": "above", "otherPositions": true})).is_err()); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "repeat": 144, "otherPositions": true})).is_err()); assert_eq!(class(&s), (false, Auto, None), "refused: nothing changes"); // Along the line it's taken, the position given with it counting, and it undoes in one step. s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "otherPositions": true})).unwrap(); assert_eq!(class(&s), (true, Curved, None)); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s), (false, Auto, None), "undoes"); s.run("labels.placement", &json!({"layer": "Rivers", "position": "parallel"})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": true})).unwrap(); assert_eq!(class(&s), (true, Parallel, None), "alone it keeps the position"); // Set on, a later position or repeat keeps it: it does nothing there, and is back on returning. s.run("labels.placement", &json!({"layer": "Rivers", "position": "above"})).unwrap(); assert_eq!(class(&s), (true, Above, None)); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": true})).is_err(), "still refused there"); s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "repeat": 144})).unwrap(); assert_eq!(class(&s), (true, Curved, Some(144.0))); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": true})).is_err(), "a repeat is in the way"); // A repeat taken away in the same call counts too. s.run("labels.placement", &json!({"layer": "Rivers", "repeat": null, "otherPositions": true})).unwrap(); assert_eq!(class(&s), (true, Curved, None)); for bad in [json!("yes"), json!(1), Value::Null] { assert!(s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": bad})).is_err(), "{bad}"); } // Polygons have no such places, even placed parallel. assert!(s.run("labels.placement", &json!({"layer": "Countries", "position": "parallel", "otherPositions": true})).is_err()); s.run("labels.placement", &json!({"layer": "Countries", "otherPositions": false})).unwrap(); // Judged in the class named, not in Default; a class that isn't there is refused. s.run("labels.placement", &json!({"layer": "Rivers", "position": "above", "otherPositions": false})).unwrap(); s.run("labels.class.add", &json!({"layer": "Rivers", "name": "Big"})).unwrap(); s.run("labels.placement", &json!({"layer": "Rivers", "class": "Big", "position": "curved", "otherPositions": true})).unwrap(); let labels = &s.map().layer(id).unwrap().labels; assert!(labels.classes[0].other_positions && !labels.default.other_positions); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "otherPositions": true})).is_err(), "Default is off the line"); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "class": "Nope", "otherPositions": true})).is_err()); assert!(s.run("labels.placement", &json!({"layer": "Rivers", "class": 7, "otherPositions": true})).is_err()); // The project keeps it. let text = serde_json::to_string(&s.project).unwrap(); assert!(Session::new(serde_json::from_str(&text).unwrap()).map().layer_by_name("Rivers").unwrap().labels.classes[0].other_positions); // Turned on or off, the labels are locked again, with the anchors of their places on the line. let key = (id, 0, 0, 0); for on in [false, true] { s.map_mut().label_lock = Some(vec![(key, rustgis_map::LockedPlace { place: 1, callout: None })]); s.map_mut().label_lock_line_anchors.insert(key, Default::default()); s.run("labels.placement", &json!({"layer": "Rivers", "class": "Big", "otherPositions": on})).unwrap(); assert!(s.map().label_lock.is_none() && s.map().label_lock_line_anchors.is_empty(), "{on}: locked again"); } } /// A label class takes an offset with its placement and a background with its text symbol; junk /// is refused, numbers kept within their ranges, and each change undoes. #[test] fn labels_take_an_offset_and_a_background() { use rustgis_map::{LabelBackground, Rgba}; let mut s = world(); let class = |s: &Session| { let c = &s.map().layer_by_name("Cities").unwrap().labels.default; (c.placement, c.offset, c.background.clone()) }; let (placement, offset, background) = class(&s); assert_eq!((offset, background), ([0.0; 2], None)); // An offset alone keeps the placement; both together set both. let mark = s.notice_mark(); s.run("labels.placement", &json!({"layer": "Cities", "offset": [3, "-2.5"]})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); assert_eq!((class(&s).0, class(&s).1), (placement, [3.0, -2.5])); s.run("labels.placement", &json!({"layer": "Cities", "position": "right", "offset": [1e9, -1e9]})).unwrap(); assert_eq!((class(&s).0, class(&s).1), (rustgis_map::LabelPlacement::Right, [200.0, -200.0])); s.run("labels.placement", &json!({"layer": "Cities", "position": "below"})).unwrap(); assert_eq!(class(&s).1, [200.0, -200.0], "a position alone keeps the offset"); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(class(&s).0, rustgis_map::LabelPlacement::Right, "undoes"); for bad in [json!({}), json!({"offset": "far"}), json!({"offset": [1]}), json!({"offset": [1, "x"]}), json!({"offset": null})] { let mut p = bad.clone(); p["layer"] = json!("Cities"); assert!(s.run("labels.placement", &p).is_err(), "{bad}"); } // A background: on (the default's), parts set on it, off. s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap(); assert_eq!(class(&s).2, Some(LabelBackground::default())); let mark = s.notice_mark(); let parts = json!({"fill": "#FFEE00", "stroke": "", "strokeWidth": 99, "padding": [2, "1"], "radius": -1}); s.run("labels.style", &json!({"layer": "Cities", "background": parts})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); let want = LabelBackground { fill: Some(Rgba::rgb(0xFF, 0xEE, 0)), stroke: None, stroke_width: 10.0, padding: [2.0, 1.0], radius: 0.0 }; assert_eq!(class(&s).2, Some(want.clone())); s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 1e300}})).unwrap(); assert_eq!(class(&s).2.map(|b| b.radius), Some(50.0), "past f32's range: the most"); s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 3}})).unwrap(); assert_eq!(class(&s).2, Some(LabelBackground { radius: 3.0, ..want.clone() }), "what isn't given is kept"); s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap(); assert_eq!(class(&s).2.map(|b| b.radius), Some(3.0), "on again keeps it"); s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); assert!(class(&s).2.is_some(), "other settings leave it"); s.run("labels.style", &json!({"layer": "Cities", "background": false})).unwrap(); assert_eq!(class(&s).2, None); s.run("edit.undo", &json!({})).unwrap(); assert!(class(&s).2.is_some(), "undoes"); s.run("labels.style", &json!({"layer": "Cities", "background": null})).unwrap(); assert_eq!(class(&s).2, None); // Set on none, the parts go on the default's. s.run("labels.style", &json!({"layer": "Cities", "background": {"fill": ""}})).unwrap(); assert_eq!(class(&s).2, Some(LabelBackground { fill: None, ..LabelBackground::default() })); for bad in [ json!({"background": "box"}), json!({"background": 1}), json!({"background": {"fill": "pink"}}), json!({"background": {"stroke": 7}}), json!({"background": {"strokeWidth": "wide"}}), json!({"background": {"padding": 2}}), json!({"background": {"padding": [2]}}), json!({"background": {"radius": null}}), ] { let mut p = bad.clone(); p["layer"] = json!("Cities"); assert!(s.run("labels.style", &p).is_err(), "{bad}"); } // The project keeps them. s.run("labels.placement", &json!({"layer": "Cities", "offset": [1, 2]})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!(class(&reopened), class(&s)); } /// A lock names the place round its point a label took by its number among its class's (#1011): a /// class trying the others locks the labels again when its position changes, not when its offset /// does; one that doesn't keeps the lock, as before. #[test] fn a_change_to_where_point_labels_go_locks_them_again() { use crate::render::View; let mut s = world(); s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); s.run("labels.lock", &json!({"on": true})).unwrap(); let draw = |s: &mut Session| { let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap(); }; draw(&mut s); s.run("labels.placement", &json!({"layer": "Cities", "position": "below"})).unwrap(); assert!(s.map().label_lock.is_some(), "no class tries other places: the lock is kept"); let id = s.map().layer_by_name("Cities").unwrap().id; s.map_mut().layer_mut(id).unwrap().labels.default.other_positions = true; s.run("labels.placement", &json!({"layer": "Cities", "offset": [2, 2]})).unwrap(); assert!(s.map().label_lock.is_some(), "an offset moves every place alike: the lock is kept"); s.run("labels.placement", &json!({"layer": "Cities", "position": "above"})).unwrap(); assert!(s.map().label_lock.is_none(), "another position: locked again"); draw(&mut s); assert!(s.map().label_lock.is_some(), "locked on the next frame"); // A style applied counts its classes' places too. s.run("style.new", &json!({"layer": "Cities", "name": "Above"})).unwrap(); s.run("labels.placement", &json!({"layer": "Cities", "position": "below"})).unwrap(); draw(&mut s); assert!(s.map().label_lock.is_some()); s.run("style.apply", &json!({"layer": "Cities", "name": "Above"})).unwrap(); assert!(s.map().label_lock.is_none(), "a style putting the labels elsewhere: locked again"); } #[test] fn changing_line_repeat_relocks_alternative_positions() { let mut s = world(); let id = s.map().layer_by_name("Rivers").unwrap().id; let c = &mut s.map_mut().layer_mut(id).unwrap().labels.default; c.other_positions = true; c.placement = rustgis_map::LabelPlacement::Parallel; s.map_mut().label_lock = Some(vec![((id, 0, 0, 0), rustgis_map::LockedPlace { place: 1, callout: None })]); s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 144})).unwrap(); assert!(s.map().label_lock.is_none()); } /// A line class's flag counts for the lock only where it does something (#1187): set but off the /// line or with a repeat it keeps the lock, as a class without it does; a change to or from the /// places it has locks the labels again. A layer also holding points keeps its flag in effect. #[test] fn a_line_class_flag_with_no_effect_keeps_the_lock() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType}; let mut s = world(); let id = s.map().layer_by_name("Rivers").unwrap().id; s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved", "otherPositions": true})).unwrap(); let held = |s: &mut Session, layer: u32, name: &str, change: Value| { s.map_mut().label_lock = Some(vec![((layer, 0, 0, 0), rustgis_map::LockedPlace { place: 1, callout: None })]); let mut p = change; p["layer"] = json!(name); s.run("labels.placement", &p).unwrap(); s.map().label_lock.is_some() }; assert!(!held(&mut s, id, "Rivers", json!({"position": "parallel"})), "another place along the line"); assert!(!held(&mut s, id, "Rivers", json!({"position": "above"})), "off the line: its places are gone"); assert!(s.map().layer(id).unwrap().labels.default.other_positions, "the flag is kept"); assert!(held(&mut s, id, "Rivers", json!({"position": "below"})), "off the line it does nothing"); assert!(held(&mut s, id, "Rivers", json!({"repeat": 144})), "nor with a repeat added there"); assert!(held(&mut s, id, "Rivers", json!({"position": "curved"})), "nor along the line with that repeat"); assert!(!held(&mut s, id, "Rivers", json!({"repeat": null})), "the repeat gone, its places are back"); // A layer of lines holding a point: the point's label goes round it at any position, so the // command takes the flag there and it counts for the lock. let mut d = Dataset::new("Mixed", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); d.push(Some(Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![rustgis_data::Value::Text("Spot".into())]); let mixed = s.map_mut().add_dataset(d); s.run("labels.placement", &json!({"layer": "Mixed", "position": "above", "otherPositions": true})).unwrap(); assert!(!held(&mut s, mixed, "Mixed", json!({"position": "below"})), "its point's places change"); } /// A lock names each label by its feature's id, not its row: deleting a feature (the rows after /// it move up) neither shows a label left out when the lock was taken nor hides one placed (#1046). #[test] fn locked_labels_stay_with_their_features_when_rows_move() { use crate::render::View; use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = world(); // Three buoys on one spot of the South Atlantic: only the first one's label fits. let mut d = Dataset::new("Buoys", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); for name in ["Alpha", "Beta", "Gamma"] { d.push(Some(Geometry::Point(rustgis_geom::Coord::new(-20.0, -30.0))), vec![V::Text(name.into())]); } let id = s.map_mut().add_dataset(d); s.run("labels.enable", &json!({"layer": "Buoys", "on": true})).unwrap(); let draw = |s: &mut Session| -> Vec { let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let labels = crate::render::render(s.map_mut(), &v).unwrap().labels; labels.into_iter().map(|l| l.text).filter(|t| ["Alpha", "Beta", "Gamma"].contains(&t.as_str())).collect() }; assert_eq!(draw(&mut s), ["Alpha"]); s.run("labels.lock", &json!({"on": true})).unwrap(); assert_eq!(draw(&mut s), ["Alpha"]); s.map_mut().layer_mut(id).unwrap().selection = vec![0]; s.run("edit.delete", &Value::Null).unwrap(); assert!(draw(&mut s).is_empty(), "Beta's label wasn't placed when the lock was taken"); s.run("labels.lock", &json!({"on": false})).unwrap(); assert_eq!(draw(&mut s), ["Beta"], "unlocked, Beta's fits"); s.run("labels.lock", &json!({"on": true})).unwrap(); assert_eq!(draw(&mut s), ["Beta"], "and is locked in turn"); } /// A feature added after locking has no label, even when it takes the id of a feature deleted /// since: the lock's entry for that id goes with the feature, and undo brings it back (#1135). #[test] fn a_new_feature_doesnt_take_the_locked_label_of_a_deleted_one() { use crate::render::View; use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = world(); let mut d = Dataset::new("Places", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); for (name, lon) in [("Alpha", -30.0), ("Gamma", 30.0)] { d.push(Some(Geometry::Point(rustgis_geom::Coord::new(lon, -40.0))), vec![V::Text(name.into())]); } // The full source id must survive lock placement, pruning and undo on wasm32 too. d.features[0].id = 1_u64 << 32; d.features[1].id = (1_u64 << 32) + 1; let id = s.map_mut().add_dataset(d); let gamma = s.map().layer(id).unwrap().dataset.features[1].id; s.run("labels.enable", &json!({"layer": "Places", "on": true})).unwrap(); let draw = |s: &mut Session| -> Vec { let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let labels = crate::render::render(s.map_mut(), &v).unwrap().labels; let mut texts: Vec = labels.into_iter().map(|l| l.text).filter(|t| ["Alpha", "Alpha 2", "Gamma", "Delta"].contains(&t.as_str())).collect(); texts.sort(); texts }; s.run("labels.lock", &json!({"on": true})).unwrap(); assert_eq!(draw(&mut s), ["Alpha", "Gamma"]); // An edit keeps the feature's id, so its label stays locked. s.run("edit.setValue", &json!({"layer": "Places", "row": 0, "field": "name", "value": "Alpha 2"})).unwrap(); assert_eq!(draw(&mut s), ["Alpha 2", "Gamma"]); s.map_mut().layer_mut(id).unwrap().selection = vec![1]; s.run("edit.delete", &Value::Null).unwrap(); let locked = |s: &Session| s.map().label_lock.as_ref().is_some_and(|k| k.iter().any(|((l, f, ..), _)| *l == id && *f == gamma)); assert!(!locked(&s), "the lock's entry goes with the feature"); let delta = json!({"layer": "Places", "geometry": {"type": "Point", "coordinates": [0.0, -40.0]}, "attributes": {"name": "Delta"}}); s.run("edit.addFeature", &delta).unwrap(); assert_eq!(s.map().layer(id).unwrap().dataset.features[1].id, gamma, "the new feature takes the deleted one's id"); assert_eq!(draw(&mut s), ["Alpha 2"], "and no label, as it wasn't placed when the lock was taken"); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert!(locked(&s), "undoing the delete brings the entry back"); assert_eq!(draw(&mut s), ["Alpha 2", "Gamma"]); s.run("edit.redo", &Value::Null).unwrap(); assert!(!locked(&s), "redoing the delete takes it away again"); assert_eq!(draw(&mut s), ["Alpha 2"]); } /// Deleting a feature removes its saved label move before its id can be reused; undo restores it. #[test] fn a_new_feature_doesnt_take_the_moved_label_of_a_deleted_one() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = Session::new(Project::default()); let mut data = Dataset::new("Sites", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); data.push(Some(Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![V::Text("Old".into())]); let layer = s.map_mut().add_dataset(data); s.run("labels.enable", &json!({"layer":layer,"on":true})).unwrap(); s.run("labels.move", &json!({"layer":layer,"feature":1,"to":[1.0,1.0]})).unwrap(); s.map_mut().layer_mut(layer).unwrap().selection = vec![0]; s.run("edit.delete", &Value::Null).unwrap(); assert!(s.map().layer(layer).unwrap().labels.default.moved.is_empty()); s.run("edit.addFeature", &json!({"layer":layer,"geometry":{"type":"Point","coordinates":[0.0,0.0]},"attributes":{"name":"New"}})) .unwrap(); assert_eq!(s.map().layer(layer).unwrap().dataset.features[0].id, 1); let view = crate::render::View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let labels = crate::render::render(s.map_mut(), &view).unwrap().labels; assert!(labels.iter().any(|l| l.text == "New" && !l.collide.pinned)); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer(layer).unwrap().labels.default.moved_to(1), Some([1.0, 1.0])); s.run("edit.redo", &Value::Null).unwrap(); assert!(s.map().layer(layer).unwrap().labels.default.moved.is_empty()); } /// Opening a hand-edited project says once per kind what its repair removed, cut or renamed /// (lists past their limit, repeated or built-in names, bad groups, an unusable projection or /// tile address, extra attribute values); the sample saved and opened again says nothing. #[test] fn opening_projects_reports_repairs() { use crate::project::MAX_ITEMS; use rustgis_map::tiles::{TileKind, TileLayer}; let clean = serde_json::to_string(&sample::world_project()).unwrap(); let reopened = Session::new(Project::from_json(&clean).unwrap()); assert!(reopened.notices.is_empty(), "{:?}", reopened.notices); let mut p = Project::from_json(&clean).unwrap(); // A saved name equal to a translation key is still literal project data. p.name = "while opening the project".into(); let m = &mut p.maps[0]; m.projection = rustgis_map::Projection::Custom("no such projection".into()); m.groups.push(rustgis_map::LayerGroup::new("No id")); m.tiles.push(TileLayer::new("Bad address", TileKind::Raster, "ftp://x/{z}")); m.layers[0].dataset.features[0].attrs.push(rustgis_data::Value::Null); let layer = m.layers[0].name.clone(); let mut v: Value = serde_json::to_value(&p).unwrap(); v["data_stores"] = (0..=MAX_ITEMS).map(|i| json!({"name": format!("Store {i}"), "path": "/data"})).collect(); v["notebooks"] = json!([{"name": "Tour"}, {"name": "tour"}]); v["ramps"] = json!([{"name": "Ember", "stops": ["#000000", "#ffffff"]}]); let opened = Session::new(Project::from_json(&v.to_string()).unwrap()); let said = |text: &str| opened.notices.iter().filter(|n| n.text.contains(text)).count(); assert_eq!(said("while opening the project"), 7, "{:?}", opened.notices); for notice in &opened.notices { let translated = notice .translated(|key, args| { let key = if key == "while opening the project" { "pendant l'ouverture du projet" } else { key }; args.iter().fold(key.to_string(), |text, (name, value)| text.replace(&format!("{{{name}}}"), value)) }) .unwrap(); assert!(translated.starts_with("while opening the project: "), "the saved project name is never translated"); assert!(translated.contains("pendant l'ouverture du projet"), "repair timing is translatable"); } assert_eq!(said(&format!("1 data store(s) beyond the {MAX_ITEMS} of each a project holds removed")), 1); assert_eq!(said("renamed with a number while opening the project: 1 notebook(s)"), 1); assert_eq!(said("1 color ramp(s) named like a built-in ramp renamed with \" (custom)\""), 1); assert_eq!(said("1 layer group(s) with no id"), 1); assert_eq!(said("1 map(s) with a projection this version can't use drawn in Web Mercator"), 1); assert_eq!(said("1 tile layer(s) with an address that can't be used removed while opening the project (\"Bad address\")"), 1); assert_eq!( said(&format!( "1 feature(s) with more attribute values than their layer has fields lost the extra values while opening the project (in \"{layer}\")" )), 1 ); // What was repaired is saved repaired: opened again, it says nothing. let again = Session::new(Project::from_json(&serde_json::to_string(&opened.project).unwrap()).unwrap()); assert!(again.notices.is_empty(), "{:?}", again.notices); } /// Content clamps and invalid group settings report their losses, then reopen quietly once repaired. #[test] fn opening_projects_reports_content_cuts_and_changed_group_settings() { let clean = serde_json::to_string(&Project::default()).unwrap(); assert!(Session::new(Project::from_json(&clean).unwrap()).notices.is_empty()); let mut project = Project::default(); let mut group = rustgis_map::LayerGroup::new("Broken parent"); group.id = 7; group.parent = Some(999); group.transparency = 150.0; group.min_scale = Some(0.0); group.max_scale = Some(-1.0); project.maps[0].groups.push(group); let mut value = serde_json::to_value(project).unwrap(); value["reports"] = json!([{ "name": "Survey", "title": "t".repeat(501), "width": 3000, "where": format!("name = '{}'", "x".repeat(2000)), "fields": vec!["name"; 65] }]); value["presentations"] = json!([{"name": "Slides", "width": 3000, "pages": [{ "title": "View", "notes": "n".repeat(20001), "seconds": 5, "kind": {"type": "blank", "text": "", "background": [255, 255, 255, 255]} }]}]); value["notebooks"] = json!([{"name": "Analysis", "cells": [{ "kind": "code", "source": "s".repeat(200001), "output": "o".repeat(20001) }]}]); let opened = Session::new(Project::from_json(&value.to_string()).unwrap()); for message in [ "2 report or presentation page-size, timing or map-view setting(s)", "4 report, presentation or notebook text field(s) shortened", "1 report filter(s) shortened to 2000 characters", "1 report field(s), group(s), statistic(s), section(s)", "3 group transparency or scale-range setting(s)", "1 group(s) with an invalid parent moved to the top level", ] { assert_eq!(opened.notices.iter().filter(|n| n.text.contains(message)).count(), 1, "{message}: {:?}", opened.notices); } assert_eq!(opened.project.reports[0].where_clause.chars().count(), 2000); assert_eq!(opened.project.reports[0].fields.len(), 64); assert_eq!(opened.project.notebooks[0].cells[0].output.as_ref().unwrap().len(), 20000); let group = &opened.map().groups[0]; assert_eq!((group.parent, group.transparency, group.min_scale, group.max_scale), (None, 100.0, None, None)); let again = Session::new(Project::from_json(&serde_json::to_string(&opened.project).unwrap()).unwrap()); assert!(again.notices.is_empty(), "{:?}", again.notices); } /// Standalone content imports publish clamp losses after success and leave clean imports quiet. #[test] fn imported_items_report_content_cuts() { for (command, clean, damaged) in [ ( "notebook.open", json!({"name": "Analysis", "cells": [{"kind": "code", "source": "", "output": "ok"}]}), json!({"name": "Analysis", "cells": [{"kind": "code", "source": "", "output": "o".repeat(20001)}]}), ), ( "presentation.import", json!({"name": "Slides"}), json!({"name": "Slides", "pages": [{"title": "View", "notes": "n".repeat(20001), "seconds": 5, "kind": {"type": "blank", "text": "", "background": [255,255,255,255]}}]}), ), ( "report.open", json!({"name": "Survey", "layer": "Countries"}), json!({"name": "Survey", "layer": "Countries", "title": "t".repeat(501), "where": "missing_column = 1", "fields": ["missing_column"], "groups": ["missing_group"], "stats": [{"field": "missing_stat", "kind": "sum"}], "sort": "missing_sort", "pictureField": "missing_picture", "attachmentsField": "missing_attachment", "chart": {"category": "missing_category"}}), ), ] { for (value, expected) in [(clean, 0), (damaged, 1)] { let mut s = Session::new(sample::world_project()); s.notices.clear(); s.run(command, &json!({"text": value.to_string()})).unwrap(); assert_eq!( s.notices.iter().filter(|n| n.text.contains("text field(s) shortened")).count(), expected, "{command}: {:?}", s.notices ); if expected == 0 { assert!(s.notices.is_empty(), "{command}: {:?}", s.notices); } if command == "report.open" && expected == 1 { assert_eq!(s.notices.iter().filter(|n| n.text.contains("1 report filter that could not be read")).count(), 1); for text in [ "1 report column(s) missing", "1 report grouping field(s) missing", "1 report statistic(s)", "4 report sort, picture, attachment or chart setting(s)", "1 report column set(s) replaced", ] { assert_eq!(s.notices.iter().filter(|n| n.text.contains(text)).count(), 1, "{text}: {:?}", s.notices); } let report = s.project.reports.last().unwrap(); assert!(report.where_clause.is_empty() && report.groups.is_empty() && report.stats.is_empty()); assert!( report.sort.is_none() && report.picture_field.is_none() && report.attachments_field.is_none() && report.chart.is_none() ); assert_eq!( report.fields, s.map().layer_by_name("Countries").unwrap().dataset.fields.iter().take(64).map(|f| f.name.clone()).collect::>() ); // The same fitting helper also runs after an existing report changes layer. let report = s.project.reports.last_mut().unwrap(); report.fields = vec!["missing_again".into()]; report.where_clause = "missing_again = 1".into(); s.notices.clear(); s.run("report.update", &json!({"layer": "Countries"})).unwrap(); assert_eq!(s.notices.iter().filter(|n| n.text.contains("1 report column(s) missing")).count(), 1); assert_eq!(s.notices.iter().filter(|n| n.text.contains("1 report filter that could not be read")).count(), 1); s.notices.clear(); s.run("report.update", &json!({"layer": "Countries"})).unwrap(); assert!(s.notices.is_empty(), "a fitted report stays quiet: {:?}", s.notices); } } } } /// Map imports audit retained maps only, and a usable tile still reports discarded dated content. #[test] fn imported_maps_scope_repairs_and_tiles_report_content_cuts() { use rustgis_map::tiles::{TileKind, TileLayer}; let mut p = Project::default(); p.maps = (0..101) .map(|i| { let mut m = p.maps[0].clone(); m.name = format!("Map {i}"); m }) .collect(); p.maps[100].groups.push(rustgis_map::LayerGroup::new("Excluded broken group")); let mut s = Session::new(Project::default()); s.run("map.import", &json!({"text": serde_json::to_string(&p).unwrap()})).unwrap(); assert_eq!(s.project.maps.len(), 101); assert!(!s.notices.iter().any(|n| n.text.contains("layer group(s)")), "{:?}", s.notices); assert_eq!(s.notices.iter().filter(|n| n.text.contains("1 map(s) beyond the import limit left out")).count(), 1); let mut p = Project::default(); let mut tile = TileLayer::new("Dated", TileKind::Raster, "https://example.com/{z}/{x}/{y}.png"); tile.time_url = Some("ftp://invalid/{date}".into()); tile.time_urls = vec![(1.0, "https://example.com/one".into()), (1.0, "https://example.com/repeated".into())]; p.maps[0].tiles.push(tile); let opened = Session::new(Project::from_json(&serde_json::to_string(&p).unwrap()).unwrap()); assert_eq!(opened.map().tiles.len(), 1); assert_eq!( opened.notices.iter().filter(|n| n.text.contains("2 invalid, repeated or excess dated tile address(es) removed")).count(), 1, "{:?}", opened.notices ); let again = Session::new(Project::from_json(&serde_json::to_string(&opened.project).unwrap()).unwrap()); assert!(again.notices.is_empty(), "{:?}", again.notices); let mut clean = Session::new(Project::default()); clean.run("map.addTiles", &json!({"url": "https://example.com/{z}/{x}/{y}.png"})).unwrap(); assert!(clean.notices.is_empty(), "an omitted optional name is a default: {:?}", clean.notices); let mut capped = Project::default(); capped.maps[0].tiles = vec![TileLayer::new("Clean", TileKind::Raster, "https://example.com/{z}/{x}/{y}.png"); rustgis_map::tiles::MAX_TILE_LAYERS]; let mut excluded = capped.maps[0].tiles[0].clone(); excluded.name = "Excluded dated tile".into(); excluded.time_url = Some("ftp://invalid/{date}".into()); capped.maps[0].tiles.push(excluded); let capped = Session::new(Project::from_json(&serde_json::to_string(&capped).unwrap()).unwrap()); assert_eq!(capped.map().tiles.len(), rustgis_map::tiles::MAX_TILE_LAYERS); assert_eq!(capped.notices.iter().filter(|n| n.text.contains("1 tile layer(s) beyond")).count(), 1); assert!(!capped.notices.iter().any(|n| n.text.contains("dated tile address(es)")), "{:?}", capped.notices); } /// Project opening reports discarded label settings once per kind; clean settings stay quiet. #[test] fn opening_projects_reports_moved_label_and_callout_adjustments() { use rustgis_data::{Dataset, Field, FieldType, GeometryType}; let mut project = Project::default(); project.maps[0].add_dataset(Dataset::new("Sites", GeometryType::Point, vec![Field::new("name", FieldType::Text)])); project.maps[0].layers[0].labels.default.move_label(1, Some([2.0, 3.0])); project.styles.push(crate::project::StyleItem { name: "Saved labels".into(), geometry: GeometryType::Point, renderer: project.maps[0].layers[0].renderer.clone(), labels: project.maps[0].layers[0].labels.clone(), transparency: 0.0, }); project.offline_areas.push(crate::project::OfflineArea { name: "Field copy".into(), map: "Map".into(), extent: rustgis_geom::Extent::new(-1.0, -1.0, 1.0, 1.0), created: 0.0, synced: 0.0, data: project.maps[0].clone(), }); let clean = serde_json::to_string(&project).unwrap(); let clean_session = Session::new(Project::from_json(&clean).unwrap()); assert!(clean_session.notices.is_empty()); // Callouts written off (false, null or 0) are clean too. for off in [json!(false), json!(null), json!(0)] { let mut v: Value = serde_json::from_str(&clean).unwrap(); v["maps"][0]["layers"][0]["labels"]["callout"] = off.clone(); v["styles"][0]["labels"]["callout"] = off.clone(); assert!(Session::new(Project::from_json(&v.to_string()).unwrap()).notices.is_empty(), "{off}"); } let mut damaged: Value = serde_json::from_str(&clean).unwrap(); let labels = &mut damaged["maps"][0]["layers"][0]["labels"]; labels["moved"] = json!([ {"id":1,"at":[2.0,3.0]}, {"id":1,"at":[4.0,5.0]}, {"id":-1,"at":[2.0,3.0]}, {"id":1.5,"at":[2.0,3.0]}, {"id":2,"at":[2.0,100.0]} ]); labels["callout"] = json!(900); labels["show_all"] = json!("yes"); damaged["styles"][0]["labels"]["moved"] = json!("damaged"); damaged["styles"][0]["labels"]["callout"] = json!(-1); let text = damaged.to_string(); let loaded = Project::from_json(&text).unwrap(); assert_eq!(loaded.maps[0].layers[0].labels.default.moved_to(1), Some([4.0, 5.0])); let startup = Session::new(loaded); assert_eq!(startup.notices.len(), 3); assert!(startup.notices.iter().any(|n| n.text.contains("5 invalid, duplicate or excess moved-label"))); assert!(startup.notices.iter().any(|n| n.text.contains("2 callout length setting(s) adjusted"))); assert!(startup.notices.iter().any(|n| n.text.contains("1 \"show all labels\" setting(s)"))); let mut opened = Session::new(Project::default()); opened.run("project.openText", &json!({"text":text})).unwrap(); assert_eq!(opened.notices.iter().filter(|n| n.text.contains("while opening the project")).count(), 3); assert!(!serde_json::to_string(&opened.project).unwrap().contains("read_notices")); let mut excess: Value = serde_json::from_str(&clean).unwrap(); excess["styles"][0]["labels"]["moved"] = Value::Array((0..=rustgis_map::symbology::MAX_MOVED_LABELS).map(|id| json!({"id":id,"at":[0.0,0.0]})).collect()); let capped = Session::new(Project::from_json(&excess.to_string()).unwrap()); assert_eq!(capped.notices.len(), 1); assert!(capped.notices[0].text.contains("1 invalid, duplicate or excess moved-label")); } /// A style's saved label moves name the features of the layer it was made from: applied, the /// layer keeps its own moves instead, and says what it left out; a style without moves is quiet. #[test] fn applying_a_style_keeps_the_layers_own_label_moves() { let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let ids: Vec = s.map().layer(id).unwrap().dataset.features.iter().take(2).map(|f| f.id).collect(); let said = |s: &Session, mark| s.notices_since(mark).iter().filter(|n| n.text.contains("label move(s)")).count(); s.run("style.new", &json!({"layer": id, "name": "Plain"})).unwrap(); s.run("labels.move", &json!({"layer": id, "feature": ids[0], "to": [1, 1]})).unwrap(); s.run("style.new", &json!({"layer": id, "name": "Moved"})).unwrap(); s.run("labels.move", &json!({"layer": id, "feature": ids[0], "to": null})).unwrap(); s.run("labels.move", &json!({"layer": id, "feature": ids[1], "to": [2, 2]})).unwrap(); // A style that places the labels as they are keeps them locked, the layer's moves and all. s.run("style.new", &json!({"layer": id, "name": "Same"})).unwrap(); s.map_mut().label_lock = Some(vec![((id, 0, 0, 0), rustgis_map::LockedPlace::default())]); s.run("style.apply", &json!({"layer": id, "name": "Same"})).unwrap(); assert!(s.map().label_lock.is_some(), "placement unchanged keeps the lock"); assert_eq!(s.map().layer(id).unwrap().labels.default.moved_to(ids[1]), Some([2.0, 2.0])); let mark = s.notice_mark(); s.run("style.apply", &json!({"layer": id, "name": "Plain"})).unwrap(); assert_eq!(said(&s, mark), 0, "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("style.apply", &json!({"layer": id, "name": "Moved"})).unwrap(); let labels = &s.map().layer(id).unwrap().labels.default; assert_eq!((labels.moved_to(ids[0]), labels.moved_to(ids[1])), (None, Some([2.0, 2.0]))); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("1 saved label move(s) in style Moved not applied"))); } /// A project template keeps no features, so no saved label moves either, and says so. #[test] fn a_project_template_leaves_out_label_moves() { let dir = std::env::temp_dir().join(format!("rustgis-template-moves-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("t.rgis"); let mut s = world(); let mark = s.notice_mark(); s.run("project.saveTemplate", &json!({"path": path})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("label move(s)"))); let id = s.map().layer_by_name("Cities").unwrap().id; let feature = s.map().layer(id).unwrap().dataset.features[0].id; s.run("labels.move", &json!({"layer": id, "feature": feature, "to": [1, 1]})).unwrap(); // A style saved from the layer holds the move too. s.run("style.new", &json!({"layer": id, "name": "Moved"})).unwrap(); let mark = s.notice_mark(); s.run("project.saveTemplate", &json!({"path": path})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("2 saved label move(s) left out of the template"))); let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert!(!saved.to_string().contains("\"moved\"")); std::fs::remove_dir_all(&dir).ok(); } /// A map file or layer file read on its own reports discarded label settings as a project does /// (Import map, Import Styles, Add Layer File); clean files stay quiet. #[test] fn map_and_layer_files_report_label_setting_losses() { let s = world(); let map = serde_json::to_value(s.map()).unwrap(); let layer = serde_json::to_value(s.map().layer_by_name("Cities").unwrap()).unwrap(); let damage = |labels: &mut Value| { labels["moved"] = json!("damaged"); labels["callout"] = json!(900); labels["show_all"] = json!(3); }; let mut bad_map = map.clone(); let i = bad_map["layers"].as_array().unwrap().iter().position(|l| l["name"] == "Cities").unwrap(); damage(&mut bad_map["layers"][i]["labels"]); let mut bad_layer = layer.clone(); damage(&mut bad_layer["labels"]); let said = |cmd: &str, text: &Value| { let mut s = world(); let mark = s.notice_mark(); s.run(cmd, &json!({"text": text.to_string()})).unwrap(); s.notices_since(mark).iter().filter(|n| n.text.contains("while opening the")).map(|n| n.text.clone()).collect::>() }; for (cmd, clean, bad) in [ ("map.import", &map, &bad_map), ("style.import", &map, &bad_map), ("style.import", &layer, &bad_layer), ("layer.addLayerFile", &layer, &bad_layer), ] { assert!(said(cmd, clean).is_empty(), "{cmd}: {:?}", said(cmd, clean)); let notes = said(cmd, bad); assert_eq!(notes.len(), 3, "{cmd}: {notes:?}"); assert!(notes.iter().any(|n| n.contains("1 invalid, duplicate or excess moved-label")), "{cmd}: {notes:?}"); assert!(notes.iter().any(|n| n.contains("1 callout length")), "{cmd}: {notes:?}"); assert!(notes.iter().any(|n| n.contains("1 \"show all labels\"")), "{cmd}: {notes:?}"); } } /// Two layers whose only features have the same id, each with a locked label and a line anchor. fn two_locked_layers() -> (Session, u32, u32) { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = Session::new(Project::default()); let mut layer = |name: &str| { let mut d = Dataset::new(name, GeometryType::Point, vec![Field::new("name", FieldType::Text)]); d.push(Some(Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![V::Text(name.into())]); s.map_mut().add_dataset(d) }; let (a, b) = (layer("A"), layer("B")); let keys = [(a, 1, 0, 0), (b, 1, 0, 0)]; s.map_mut().label_lock = Some(keys.map(|k| (k, Default::default())).to_vec()); s.map_mut().label_lock_line_anchors = keys.map(|k| (k, Default::default())).into_iter().collect(); (s, a, b) } /// The layers a map's lock and line anchors name, each sorted. fn locked_layers(s: &Session) -> (Vec, Vec) { let mut lock: Vec = s.map().label_lock.iter().flatten().map(|((l, ..), _)| *l).collect(); let mut anchors: Vec = s.map().label_lock_line_anchors.keys().map(|(l, ..)| *l).collect(); lock.sort_unstable(); anchors.sort_unstable(); (lock, anchors) } /// A removed layer's locked labels and line anchors go with it, and only its own; undo brings them /// back. #[test] fn a_removed_layers_locked_labels_go_with_it() { let (mut s, a, b) = two_locked_layers(); s.run("layer.remove", &json!({"layer": a})).unwrap(); assert_eq!(locked_layers(&s), (vec![b], vec![b])); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![a, b], vec![a, b])); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![b], vec![b])); // The last locked layer gone, the lock holds, with nothing in it. s.run("layer.remove", &json!({"layer": b})).unwrap(); assert!(s.map().label_lock.as_ref().is_some_and(Vec::is_empty)); assert!(s.map().label_lock_line_anchors.is_empty()); } /// A deleted feature's lock entry and line anchor go with it, not those of another layer's feature /// with the same id; undo brings them back. #[test] fn a_deleted_feature_leaves_another_layers_lock_alone() { let (mut s, a, b) = two_locked_layers(); s.map_mut().layer_mut(a).unwrap().selection = vec![0]; s.run("edit.delete", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![b], vec![b])); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![a, b], vec![a, b])); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(locked_layers(&s), (vec![b], vec![b])); } /// Deleting one of a layer's locked features keeps the lock entry and line anchor of the other. #[test] fn a_deleted_feature_leaves_its_neighbours_lock_alone() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut s = Session::new(Project::default()); let mut d = Dataset::new("A", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); for name in ["one", "two"] { d.push(Some(Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![V::Text(name.into())]); } let a = s.map_mut().add_dataset(d); let keys = [(a, 1, 0, 0), (a, 2, 0, 0)]; s.map_mut().label_lock = Some(keys.map(|k| (k, Default::default())).to_vec()); s.map_mut().label_lock_line_anchors = keys.map(|k| (k, Default::default())).into_iter().collect(); // The feature ids the lock and the anchors name, each sorted. let ids = |s: &Session| { let mut lock: Vec = s.map().label_lock.iter().flatten().map(|((_, f, ..), _)| *f).collect(); let mut anchors: Vec = s.map().label_lock_line_anchors.keys().map(|(_, f, ..)| *f).collect(); lock.sort_unstable(); anchors.sort_unstable(); (lock, anchors) }; s.map_mut().layer_mut(a).unwrap().selection = vec![0]; s.run("edit.delete", &Value::Null).unwrap(); assert_eq!(ids(&s), (vec![2], vec![2])); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(ids(&s), (vec![1, 2], vec![1, 2])); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(ids(&s), (vec![2], vec![2])); } /// A streamed layer holds only the window in view: a lock's entry for a feature outside it stays /// when the window changes (#1135). #[test] fn a_lock_holds_for_a_streamed_layers_features_outside_its_window() { let p = line_of_points_shp("locked-window", 20); let mut s = world(); let r = s.run("layer.addData", &json!({"path": p.display().to_string(), "stream": true})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; s.map_mut().label_lock = Some(vec![((id, 1, 0, 0), Default::default())]); let before = s.project.clone(); // The window moves off the feature: no longer shared with the copy, and without its id. s.map_mut().layer_mut(id).unwrap().dataset.features = Vec::new().into(); crate::forget_label_state_of_removed_features(&before, &mut s.project); assert_eq!(s.map().label_lock.as_ref().map(Vec::len), Some(1)); drop(s); remove_shp(&p); } /// A lock names each label by its class's place in the list: a change to the list (a class added, /// moved or removed) locks the labels again on the next frame, a change within a class keeps the /// lock (#732). #[test] fn a_change_to_the_label_classes_locks_the_labels_again() { use crate::render::View; let mut s = world(); s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); s.run("labels.lock", &json!({"on": true})).unwrap(); let draw = |s: &mut Session| { let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap(); }; draw(&mut s); assert!(s.map().label_lock.is_some()); s.run("labels.style", &json!({"layer": "Cities", "size": 14})).unwrap(); s.run("labels.class.update", &json!({"layer": "Cities", "visible": true})).unwrap(); assert!(s.map().label_lock.is_some(), "a change within a class keeps the lock"); for (id, p) in [ ("labels.class.add", json!({"layer": "Cities", "name": "Big"})), ("labels.class.add", json!({"layer": "Cities", "name": "Small"})), ("labels.class.move", json!({"layer": "Cities", "class": "Small", "to": 0})), ("labels.class.remove", json!({"layer": "Cities", "class": "Big"})), ("labels.class.clear", json!({"layer": "Cities"})), ] { s.run(id, &p).unwrap(); assert!(s.map().label_lock.is_none(), "{id} moves the classes: locked again"); draw(&mut s); assert!(s.map().label_lock.is_some(), "{id}: locked on the next frame"); } } /// Changes that affect collision placement discard the lock; a new class inherits styling, /// but not feature-specific manual positions. #[test] fn label_collision_changes_refresh_locks_without_copying_moves() { let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let feature = s.map().layer(id).unwrap().dataset.features[0].id; for params in [json!({"showAll":true}), json!({"callout":true}), json!({"callout":false})] { s.map_mut().label_lock = Some(vec![((id, feature, 0, 0), Default::default())]); let mut p = params; p["layer"] = json!(id); s.run("labels.placement", &p).unwrap(); assert!(s.map().label_lock.is_none(), "{p} changes collision placement"); } s.run("labels.move", &json!({"layer":id,"feature":feature,"to":[1,1]})).unwrap(); s.run("labels.class.add", &json!({"layer":id,"name":"New"})).unwrap(); let labels = &s.map().layer(id).unwrap().labels; assert_eq!(labels.default.moved.len(), 1); assert!(labels.class("New").unwrap().moved.is_empty()); } /// A service refresh assigns new ids: default and named-class moves must not attach to them, /// and the discarded moves are counted. Undo restores the old features and positions. #[test] fn service_refresh_resets_saved_label_moves_with_a_notice() { let server = ogc_server::Server::group_fixture(); let mut s = Session::new(Project::default()); s.run("service.import", &json!({"url":server.root()})).unwrap(); let id = s.map().layer_by_name("Places").unwrap().id; let feature = s.map().layer(id).unwrap().dataset.features[0].id; s.run("labels.class.add", &json!({"layer":id,"name":"Named"})).unwrap(); for class in [None, Some("Named")] { let mut p = json!({"layer":id,"feature":feature,"to":[1,1]}); if let Some(class) = class { p["class"] = json!(class); } s.run("labels.move", &p).unwrap(); } let mark = s.notice_mark(); s.run("service.refresh", &json!({"layer":id})).unwrap(); assert!(s.map().layer(id).unwrap().labels.in_order().all(|c| c.moved.is_empty())); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("2 saved label move(s) reset"))); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layer(id).unwrap().labels.in_order().map(|c| c.moved.len()).sum::(), 2); s.run("edit.redo", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("service.refresh", &json!({"layer":id})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("saved label move(s) reset"))); } /// A refresh whose service no longer offers a layer says how many saved moves went with it. #[test] fn service_refresh_says_how_many_moves_go_with_a_dropped_layer() { let server = ogc_server::Server::group_fixture(); let mut s = Session::new(Project::default()); let group = s.run("service.import", &json!({"url":server.root()})).unwrap()["group"].clone(); let routes = s.map().layer_by_name("Routes").unwrap().id; let feature = s.map().layer(routes).unwrap().dataset.features[0].id; s.run("labels.move", &json!({"layer":routes,"feature":feature,"to":[1,1]})).unwrap(); // The service now offers only Places. let items = json!({"rel": "items", "type": "application/geo+json", "href": "collections/places/items?key=fixture-key"}); server.set(ogc_server::CATALOG, &json!({"collections": [{"id": "places", "title": "Places", "links": [items]}]}).to_string()); let mark = s.notice_mark(); // The whole group: a refresh of one layer leaves its siblings as they are. s.run("service.refresh", &json!({"group":group})).unwrap(); assert!(s.map().layer_by_name("Routes").is_none()); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("1 saved label move(s) dropped with layers"))); } /// Label classes by command (#618): added from Default and checked before it, renamed, given /// a clause, moved, styled and removed; refusals change nothing, and Default stays last. #[test] fn label_classes_by_command() { use crate::map::LabelPlacement; let mut s = world(); let cities = s.map().layer_by_name("Cities").unwrap().id; s.run("layer.select", &json!({"layer": cities})).unwrap(); let labels = |s: &Session| s.map().layer(cities).unwrap().labels.clone(); let order = |s: &Session| labels(s).in_order().map(|c| c.name.clone()).collect::>(); assert_eq!(s.run("labels.class.add", &json!({"name": "Big", "where": "pop_max > 5000000"})).unwrap()["class"], "Big"); s.run("labels.class.add", &json!({"name": " Medium ", "where": "pop_max > 1000000"})).unwrap(); assert_eq!(order(&s), ["Big", "Medium", "Default"]); let l = labels(&s); assert_eq!((l.classes[0].size, l.classes[0].field.as_str()), (l.default.size, l.default.field.as_str()), "a class starts as Default"); for bad in [ json!({"name": "big"}), json!({"name": "default"}), json!({"name": " "}), json!({"name": "X", "where": "(("}), json!({"name": "X", "where": "nope = 1"}), json!({"name": "X", "where": 5}), json!({"name": "X", "from": "Nope"}), ] { assert!(s.run("labels.class.add", &bad).is_err(), "{bad}"); } assert!(s.run("labels.class.remove", &json!({"class": "Default"})).is_err(), "Default stays"); assert!(s.run("labels.class.move", &json!({"class": "Default", "to": 0})).is_err(), "and stays last"); assert!(s.run("labels.class.rename", &json!({"class": "Big", "name": "medium"})).is_err()); assert!(s.run("labels.class.update", &json!({"class": "Big", "where": "(("})).is_err()); assert!(s.run("labels.style", &json!({"class": "Nope", "size": 30})).is_err()); // A class that isn't a name is refused, not taken for Default. for bad in [json!(3), json!(" "), json!(["Big"])] { assert!(s.run("labels.style", &json!({"class": bad, "size": 30})).is_err(), "{bad}"); assert!(s.run("labels.class.add", &json!({"name": "X", "from": bad})).is_err(), "{bad}"); } assert_eq!(labels(&s), l, "refusals change nothing"); // A field for a class that isn't there turns nothing on; one for a class labels the layer. s.run("labels.enable", &json!({"on": false})).unwrap(); assert!(s.run("labels.field", &json!({"class": "Nope", "field": "name"})).is_err()); assert!(!labels(&s).enabled); s.run("labels.field", &json!({"class": "Big", "field": "name"})).unwrap(); assert!(labels(&s).enabled && labels(&s).classes[0].field == "name"); // The labels.* commands change the class named, Default when none is. s.run("labels.style", &json!({"class": "big", "size": 20, "bold": true})).unwrap(); s.run("labels.placement", &json!({"class": "Big", "position": "right"})).unwrap(); s.run("labels.scaleRange", &json!({"class": "Medium", "min": 10000000})).unwrap(); s.run("labels.field", &json!({"class": "Medium", "field": "pop_max"})).unwrap(); let l = labels(&s); assert_eq!((l.classes[0].size, l.classes[0].bold, l.classes[0].placement), (20.0, true, LabelPlacement::Right)); assert_eq!((l.classes[1].min_scale, l.classes[1].field.as_str()), (Some(10_000_000.0), "pop_max")); assert!(l.default.size < 20.0 && l.default.field == "name", "Default is left as it was"); // Renamed (Default too), moved, its clause cleared, hidden; each one undo step. s.run("labels.class.rename", &json!({"class": "Medium", "name": "Large"})).unwrap(); s.run("labels.class.move", &json!({"class": "Large", "to": 0})).unwrap(); s.run("labels.class.update", &json!({"class": "Big", "where": null, "visible": false})).unwrap(); s.run("labels.class.rename", &json!({"class": "Default", "name": "Other"})).unwrap(); assert_eq!(order(&s), ["Large", "Big", "Other"]); let l = labels(&s); assert_eq!((l.classes[1].where_.as_deref(), l.classes[1].visible), (None, false)); s.run("edit.undo", &json!(null)).unwrap(); assert_eq!(order(&s), ["Large", "Big", "Default"]); let info = s.run("info.project", &json!(null)).unwrap().to_string(); assert!(info.contains("labelClasses") && info.contains("Large"), "info.project lists the classes"); s.run("labels.class.remove", &json!({"class": "big"})).unwrap(); assert_eq!(order(&s), ["Large", "Default"]); // Clear removes the rest, Default kept; again, with none left, it is no error (a script // that starts with it runs again). assert_eq!(s.run("labels.class.clear", &json!({})).unwrap()["removed"], 1); assert_eq!(order(&s), ["Default"]); assert_eq!(s.run("labels.class.clear", &json!({})).unwrap()["removed"], 0); // info.project lists the classes also for a Default alone that is hidden (or has a clause). let listed = |s: &mut Session| { let info = s.run("info.project", &json!(null)).unwrap(); info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Cities").unwrap()["labelClasses"].clone() }; assert!(listed(&mut s).is_null(), "a plain Default isn't listed"); s.run("labels.class.update", &json!({"visible": false})).unwrap(); assert_eq!(listed(&mut s)[0]["visible"], false); s.run("labels.class.update", &json!({"visible": true, "where": "pop_max > 0"})).unwrap(); assert_eq!(listed(&mut s)[0]["where"], "pop_max > 0"); s.run("labels.class.update", &json!({"where": null})).unwrap(); // A long name is cut at 100 characters, and doesn't end in the space it was cut at. let long = format!("{} tail", "a".repeat(99)); assert_eq!(s.run("labels.class.add", &json!({"name": long})).unwrap()["class"], "a".repeat(99)); } /// Converting labels with classes keeps each class's features apart: the annotation has the same /// classes, picking their points by a CLASS field (#618). Deleting a field a class labels with /// moves it to another field and keeps the class. #[test] fn label_classes_convert_and_outlive_a_deleted_field() { let mut s = world(); let cities = s.map().layer_by_name("Cities").unwrap().id; s.run("layer.select", &json!({"layer": cities})).unwrap(); s.run("labels.enable", &json!({"on": true})).unwrap(); s.run("labels.class.add", &json!({"name": "Big's", "where": "pop_max > 5000000"})).unwrap(); s.run("labels.style", &json!({"class": "Big's", "size": 18})).unwrap(); let big = rustgis_data::query::select("pop_max > 5000000", &s.map().layer(cities).unwrap().dataset).unwrap().len(); assert!(big > 0); let r = s.run("labels.convert", &json!({})).unwrap(); let a = s.map().layer(u32::try_from(r["layer"].as_u64().unwrap()).unwrap()).unwrap(); let classes: Vec<_> = a.labels.in_order().map(|c| (c.name.clone(), c.where_.clone(), c.field.clone(), c.size)).collect(); assert_eq!(classes[0], ("Big's".to_string(), Some(r#""CLASS" = 'Big''s'"#.to_string()), "TEXT".to_string(), 18.0)); assert_eq!((classes[1].1.as_deref(), classes[1].2.as_str()), (Some(r#""CLASS" = 'Default'"#), "TEXT"), "Default picks its own"); let picked = |w: &Option| rustgis_data::query::select(w.as_deref().unwrap(), &a.dataset).unwrap().len(); assert_eq!(picked(&classes[0].1), big, "the class's clause picks the points it labelled"); // Default labels the big cities too (#732): each class gives them a point. assert_eq!(picked(&classes[1].1), a.dataset.features.len() - big); assert!(picked(&classes[1].1) > big, "Default labels every city, the big ones among them"); // A deleted field: the class labels with Default's field, and is kept. s.run("layer.select", &json!({"layer": cities})).unwrap(); s.run("labels.field", &json!({"class": "Big's", "field": "pop_max"})).unwrap(); s.run("edit.deleteField", &json!({"layer": cities, "field": "pop_max"})).unwrap(); let l = &s.map().layer(cities).unwrap().labels; assert_eq!(l.classes.len(), 1); assert_eq!(l.classes[0].field, l.default.field); assert!(l.default.field != "pop_max"); assert!(l.enabled, "another class's field going leaves the labels on"); // Default on a deleted field starts again on another, keeping its name, clause and visibility. assert_eq!(l.default.field, "name"); s.run("labels.class.rename", &json!({"class": "Default", "name": "Rest"})).unwrap(); s.run("labels.class.update", &json!({"where": "name <> 'x'", "visible": false})).unwrap(); s.run("edit.deleteField", &json!({"layer": cities, "field": "name"})).unwrap(); let l = &s.map().layer(cities).unwrap().labels; assert!(!l.enabled && l.default.field != "name", "Default's field going turns the labels off, as before classes"); assert_eq!( (l.classes.len(), l.classes[0].field.as_str()), (1, l.default.field.as_str()), "the other classes are kept, on its new field" ); assert_eq!((l.default.name.as_str(), l.default.visible, l.default.where_.as_deref()), ("Rest", false, Some("name <> 'x'"))); } #[test] fn graphics_draw_shapes_text_and_pictures() { use crate::render::View; use base64::Engine; let mut s = world(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 20_000_000})).unwrap(); // The first graphic makes a graphics layer. let r = s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 45], [10, 55]], "fill": "#FF000040"})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().graphics); for (shape, pts) in [ ("circle", json!([[5, 50], [8, 50]])), ("ellipse", json!([[0, 40], [20, 48]])), ("line", json!([[0, 40], [5, 45], [10, 40]])), ("curve", json!([[0, 40], [5, 45], [10, 40]])), ("freehand", json!([[0, 40], [1, 41], [2, 40]])), ("polygon", json!([[0, 40], [5, 45], [10, 40]])), ("lasso", json!([[0, 40], [5, 45], [10, 40], [5, 38]])), ("point", json!([[2, 48]])), ] { s.run("graphics.add", &json!({"shape": shape, "points": pts})).unwrap_or_else(|e| panic!("{shape}: {e}")); } assert!(s.run("graphics.add", &json!({"shape": "polygon", "points": [[0, 0], [1, 1]]})).is_err()); assert!(s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 0], [0, 1]]})).is_err()); assert!(s.run("graphics.add", &json!({"shape": "text", "points": [[0, 0]]})).is_err(), "text needs text"); assert!(s.run("graphics.add", &json!({"shape": "blob", "points": [[0, 0]]})).is_err()); assert!(s.run("graphics.add", &json!({"shape": "point", "points": [[0, 0]], "layer": "Cities"})).is_err(), "not a graphics layer"); // Dynamic text expands when drawn. let text = s.run("graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "{map} {scale}", "fontSize": 20})).unwrap()["row"] .clone(); let view = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let r = crate::render::render(s.map_mut(), &view).unwrap(); assert!( r.labels.iter().any(|l| l.text.starts_with("World 1:") && l.size == 20.0), "{:?}", r.labels.iter().map(|l| &l.text).collect::>() ); // A picture: a 2×2 PNG. let mut png = tiny_skia::Pixmap::new(2, 2).unwrap(); png.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let b64 = base64::engine::general_purpose::STANDARD.encode(png.encode_png().unwrap()); s.run("graphics.add", &json!({"shape": "picture", "points": [[0, 45], [10, 55]], "image": b64})).unwrap(); assert!(s.run("graphics.add", &json!({"shape": "picture", "points": [[0, 45], [10, 55]], "image": "bm90IGFuIGltYWdl"})).is_err()); let v = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let r = crate::render::render(s.map_mut(), &v).unwrap(); let (x, y) = v.to_screen(rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(5.0, 50.0))); let px = r.pixmap.pixel(x as u32, y as u32).unwrap(); assert!(px.red() > 200 && px.green() < 60, "the picture covers its rectangle: {px:?}"); // Restyle the selection; keep the tool. s.run("select.rows", &json!({"layer": id, "rows": [0]})).unwrap(); s.run("graphics.style", &json!({"fill": "#00FF00", "width": 3})).unwrap(); assert_eq!(rustgis_map::graphics::symbol(&s.map().layer(id).unwrap().dataset, 0).fill, rustgis_map::Rgba::rgb(0, 255, 0)); assert!(s.run("graphics.style", &json!({})).is_err(), "nothing to change"); s.run("graphics.style", &json!({"text": "not on a rectangle"})).unwrap(); assert!(rustgis_map::graphics::label(&s.map().layer(id).unwrap().dataset, 0).is_none(), "text only goes on text graphics"); // Edit one text graphic by layer and row (#420): the selected rectangle stays as it was, and // undo takes the edit back. s.run("graphics.edit", &json!({"layer": id, "row": text, "text": "Edited", "fontSize": 30})).unwrap(); let row = text.as_u64().unwrap() as usize; let ds = &s.map().layer(id).unwrap().dataset; assert_eq!((rustgis_map::graphics::label(ds, row), rustgis_map::graphics::font_size(ds, row)), (Some("Edited"), 30.0)); assert_eq!(rustgis_map::graphics::font_size(ds, 0), 14.0, "only the graphic named"); // A size is kept within the range it is drawn at, so Edit Text shows what the map draws. s.run("graphics.edit", &json!({"layer": id, "row": text, "fontSize": 0})).unwrap(); let ds = &s.map().layer(id).unwrap().dataset; assert_eq!(ds.value(row, ds.field_index("font_size").unwrap()), &rustgis_data::Value::Float(*rustgis_map::graphics::FONT_SIZE.start())); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); let ds = &s.map().layer(id).unwrap().dataset; assert_eq!((rustgis_map::graphics::label(ds, row), rustgis_map::graphics::font_size(ds, row)), (Some("{map} {scale}"), 20.0)); for bad in [ json!({"layer": id, "row": text, "text": " "}), json!({"layer": id, "row": 999, "text": "x"}), json!({"layer": id, "row": 0.5, "text": "x"}), json!({"layer": id, "text": "x"}), json!({"row": text, "text": "x"}), json!({"layer": null, "row": text, "text": "x"}), json!({"layer": id, "row": 0, "text": "not on a rectangle"}), json!({"layer": id, "row": text, "text": format!("{}x", " ".repeat(2000))}), json!({"layer": id, "row": text}), json!({"layer": "Cities", "row": 0, "text": "x"}), ] { assert!(s.run("graphics.edit", &bad).is_err(), "{bad}"); } assert_eq!(rustgis_render::picture_size(&b64), Some((2, 2))); assert!(s.run("graphics.keepTool", &Value::Null).unwrap()["keepTool"].as_bool().unwrap()); s.run("tool.set", &json!({"tool": "graphicRectangle"})).unwrap(); assert_eq!(s.tool.code(), "graphicRectangle"); // Graphics round-trip through a project file. let text = serde_json::to_string(&s.project).unwrap(); let p: crate::Project = serde_json::from_str(&text).unwrap(); assert!(p.map().layer(id).unwrap().graphics); s.run("graphics.newLayer", &json!({"name": "Notes"})).unwrap(); assert!(s.map().layer_by_name("Notes").unwrap().graphics); } #[test] fn select_takes_only_drawn_features_of_selectable_layers() { let mut s = world(); let selected = |s: &Session, layer: &str| s.map().layer_by_name(layer).map(|l| l.selection.clone()).unwrap_or_default(); // A display filter shows only South America: a box over the world selects only what's drawn (#243). let filters = json!({"layer": "Countries", "filters": [{"name": "SA", "where": "continent = 'South America'"}]}); s.run("layer.displayFilters", &filters).unwrap(); let all = s.map().layer_by_name("Countries").unwrap().dataset.features.len(); let r = s.run("select.byRect", &json!({"minLon": -180, "minLat": -85, "maxLon": 180, "maxLat": 85})).unwrap(); let rows = selected(&s, "Countries"); assert!(!rows.is_empty() && rows.len() < all, "{} of {all}", rows.len()); let l = s.map().layer_by_name("Countries").unwrap(); let continent = l.dataset.field_index("continent").unwrap(); assert!(rows.iter().all(|r| l.dataset.value(*r, continent).to_string() == "South America")); assert!(r["selected"].as_u64().unwrap() >= rows.len() as u64); // A click on a hidden country selects nothing; on a drawn one, it. let r = s.run("select.atPoint", &json!({"lon": 2.35, "lat": 46.6, "tolerancePx": 0})).unwrap(); assert!(r.is_null(), "{r}"); assert!(selected(&s, "Countries").is_empty()); s.run("select.atPoint", &json!({"lon": -51.0, "lat": -10.0, "tolerancePx": 0})).unwrap(); assert_eq!(selected(&s, "Countries").len(), 1); // A layer that isn't selectable doesn't hide the selectable ones under it: Paris is in Cities, // above France in Countries. s.run("layer.displayFilters", &json!({"layer": "Countries", "filters": []})).unwrap(); let paris = json!({"lon": 2.35, "lat": 48.85, "tolerancePx": 5}); s.run("select.atPoint", &paris).unwrap(); assert_eq!(selected(&s, "Cities").len(), 1); s.run("layer.selectable", &json!({"layer": "Cities", "on": false})).unwrap(); s.run("select.atPoint", &paris).unwrap(); assert!(selected(&s, "Cities").is_empty()); assert_eq!(selected(&s, "Countries").len(), 1); } fn map_area_selection_add( s: &mut Session, name: &str, kind: rustgis_data::GeometryType, geometries: &[Option], ) -> u32 { use rustgis_data::{Dataset, Field, FieldType, Value as V}; let mut dataset = Dataset::new(name, kind, vec![Field::new("n", FieldType::Integer)]); for (row, geometry) in geometries.iter().enumerate() { dataset.push(geometry.clone(), vec![V::Int(row as i64)]); } s.map_mut().add_dataset(dataset) } fn map_area_selection_fixture() -> (Session, u32, u32) { use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::Coord; let mut s = Session::new(Project::default()); let geometries: Vec<_> = [(0.5, 0.5), (2.0, 2.0), (3.0, 0.5), (4.0, 4.0)].into_iter().map(|(x, y)| Some(Geometry::Point(Coord::new(x, y)))).collect(); let first = map_area_selection_add(&mut s, "Points", GeometryType::Point, &geometries); let second = map_area_selection_add(&mut s, "Other points", GeometryType::Point, &geometries); s.map_mut().layer_mut(second).unwrap().selectable = false; (s, first, second) } fn map_area_selection_rows(s: &Session, id: u32) -> Vec { s.map().layer(id).unwrap().selection.clone() } fn map_area_selection_snapshot(s: &Session) -> Vec<(u32, Vec)> { s.map().layers.iter().map(|layer| (layer.id, layer.selection.clone())).collect() } #[test] fn map_area_selection_command_modes_count_hits_across_layers() { let operations = [ ( "select.byPolygon", json!({"points": [[0,0],[3,0],[3,1],[1,1],[1,3],[0,3]]}), json!({"points": [[10,10],[11,10],[11,11],[10,11]]}), ), ("select.byRadius", json!({"lon": 1.75, "lat": 0.5, "radius": 145000}), json!({"lon": 10, "lat": 10, "radius": 1})), ]; for (command, query, disjoint) in operations { for second_selectable in [false, true] { for mode in [None, Some("NeW"), Some("ADD"), Some("ReMoVe")] { let (mut s, first, second) = map_area_selection_fixture(); s.map_mut().layer_mut(second).unwrap().selectable = second_selectable; s.map_mut().layer_mut(first).unwrap().selection = vec![0, 1]; s.map_mut().layer_mut(second).unwrap().selection = vec![0, 3]; let mut params = query.clone(); if let Some(mode) = mode { params["mode"] = json!(mode); } let result = s.run(command, ¶ms).unwrap(); assert_eq!(result, json!({"selected": if second_selectable { 4 } else { 2 }}), "{command}: {mode:?}"); let (first_expected, second_expected) = match mode { Some("ADD") => (vec![0, 1, 2], if second_selectable { vec![0, 2, 3] } else { vec![0, 3] }), Some("ReMoVe") => (vec![1], if second_selectable { vec![3] } else { vec![0, 3] }), _ => (vec![0, 2], if second_selectable { vec![0, 2] } else { vec![] }), }; assert_eq!(map_area_selection_rows(&s, first), first_expected, "{command}: {mode:?}"); assert_eq!(map_area_selection_rows(&s, second), second_expected, "{command}: {mode:?}"); let before = map_area_selection_snapshot(&s); let mut empty = disjoint.clone(); if let Some(mode) = mode { empty["mode"] = json!(mode); } assert_eq!(s.run(command, &empty).unwrap(), json!({"selected": 0})); if matches!(mode, Some("ADD" | "ReMoVe")) { assert_eq!(map_area_selection_snapshot(&s), before, "{command}: {mode:?}"); } else { assert!(s.map().layers.iter().all(|layer| layer.selection.is_empty())); } } } } } #[test] fn map_area_selection_ring_forms_and_even_odd_lasso_are_command_visible() { let (mut s, first, _) = map_area_selection_fixture(); for points in [ json!([[0, 0], [3, 0], [3, 1], [1, 1], [1, 3], [0, 3]]), json!([[0, 0], [3, 0], [3, 1], [1, 1], [1, 3], [0, 3], [0, 0]]), json!([[0, 0], [0, 0], [3, 0], [3, 0], [3, 1], [1, 1], [1, 3], [0, 3], [0, 0]]), ] { assert_eq!(s.run("select.byPolygon", &json!({"points": points})).unwrap(), json!({"selected": 2})); assert_eq!(map_area_selection_rows(&s, first), [0, 2]); } use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::{Coord, lonlat_to_merc, merc_to_lonlat}; // Construct the bow-tie in the command's planar space, then invert it. Its crossing really // is (2,2), rather than the slightly different intersection of four Mercator-projected degrees. let origin = lonlat_to_merc(Coord::new(0.0, 0.0)); let at = |x, y| merc_to_lonlat(Coord::new(origin.x + x * 10000.0, origin.y + y * 10000.0)); let mut s = Session::new(Project::default()); let geometries: Vec<_> = [(2.0, 0.5), (2.0, 3.5), (0.5, 2.0), (2.0, 2.0), (4.5, 4.5)].into_iter().map(|(x, y)| Some(Geometry::Point(at(x, y)))).collect(); let id = map_area_selection_add(&mut s, "Lasso lobes", GeometryType::Point, &geometries); let points: Vec<_> = [(0.0, 0.0), (4.0, 4.0), (0.0, 4.0), (4.0, 0.0)] .into_iter() .map(|(x, y)| { let p = at(x, y); [p.x, p.y] }) .collect(); assert_eq!(s.run("select.byPolygon", &json!({"points": points})).unwrap(), json!({"selected": 3})); assert_eq!(map_area_selection_rows(&s, id), [0, 1, 3]); } #[test] fn map_area_selection_rejected_inputs_preserve_exact_layer_selections() { let (mut s, first, second) = map_area_selection_fixture(); s.map_mut().layer_mut(first).unwrap().selection = vec![1, 3]; s.map_mut().layer_mut(second).unwrap().selection = vec![0, 2]; let before = map_area_selection_snapshot(&s); let mut invalid = vec![ ("select.byPolygon", Value::Null), ("select.byPolygon", json!({"points": []})), ("select.byPolygon", json!({"points": [[0,0],[1,1]]})), ("select.byPolygon", json!({"points": [[0,0],[0,0],[0,0],[0,0]]})), ("select.byPolygon", json!({"points": [[0,0],[1,0],[2,0]]})), ("select.byPolygon", json!({"points": [[0,0],[1,null],[1,1]]})), ("select.byPolygon", json!({"points": [[0,0],[1,0,2],[1,1]]})), ("select.byPolygon", json!({"points": [[0,0],[181,0],[1,1]]})), ("select.byPolygon", json!({"points": [[0,0],[1,-91],[1,1]]})), ("select.byPolygon", json!({"points": [[179,0],[-179,0],[-179,1],[179,1]]})), // Only the closing edge crosses the Mercator seam. ("select.byPolygon", json!({"points": [[-170,0],[-10,1],[10,1],[170,0]]})), ("select.byPolygon", json!({"points": [[0,86],[1,86],[1,87]]})), ("select.byPolygon", json!({"points": vec![[0.0,0.0]; rustgis_analysis::selection::MAX_POINTS + 1]})), ("select.byRadius", json!({"lon": 0, "lat": 0})), ("select.byRadius", json!({"lon": null, "lat": 0, "radius": 1})), ("select.byRadius", json!({"lon": 181, "lat": 0, "radius": 1})), ("select.byRadius", json!({"lon": 0, "lat": 86, "radius": 1})), ("select.byRadius", json!({"lon": 179.99, "lat": 0, "radius": 100000})), ("select.byRadius", json!({"lon": 0, "lat": 85, "radius": 100000})), ]; for radius in [json!(0), json!(-1), json!(5000001), json!("not a number"), Value::Null] { invalid.push(("select.byRadius", json!({"lon": 0, "lat": 0, "radius": radius}))); } for mode in [json!("subset"), json!("bogus"), json!(false), json!(1), json!([]), Value::Null] { invalid.push(("select.byPolygon", json!({"points": [[0,0],[3,0],[3,3],[0,3]], "mode": mode}))); invalid.push(("select.byRadius", json!({"lon": 0, "lat": 0, "radius": 1000, "mode": mode}))); } for (command, params) in invalid { assert!(s.run(command, ¶ms).is_err(), "{command}: {params}"); assert_eq!(map_area_selection_snapshot(&s), before, "{command}: {params}"); } // Many proper crossings, but well below the vertex cap: refusal must come from the // normalization work guard, not an arbitrary reduction in supported lasso detail. let star: Vec<_> = (0..601) .map(|i| { let angle = (i * 300 % 601) as f64 * std::f64::consts::TAU / 601.0; [angle.cos(), angle.sin()] }) .collect(); let error = s.run("select.byPolygon", &json!({"points": star})).unwrap_err(); assert!(error.contains("too complex"), "{error}"); assert_eq!(map_area_selection_snapshot(&s), before); s.run("map.projection", &json!({"type": "robinson", "lon_0": 150})).unwrap(); for (command, params) in [ ("select.byPolygon", json!({"points": [[-35,5],[-25,5],[-25,15],[-35,15]]})), ("select.byRadius", json!({"lon": -30, "lat": 10, "radius": 100000})), ] { assert!(s.run(command, ¶ms).is_err(), "{command}: {params}"); assert_eq!(map_area_selection_snapshot(&s), before); } } #[test] fn map_area_selection_detailed_low_crossing_outline_is_not_truncated() { let (mut s, first, _) = map_area_selection_fixture(); let count = 12000; assert!(count < rustgis_analysis::selection::MAX_POINTS); let points: Vec<_> = (0..count) .map(|i| { let angle = i as f64 * std::f64::consts::TAU / count as f64; [0.5 + 0.25 * angle.cos(), 0.5 + 0.25 * angle.sin()] }) .collect(); assert_eq!(s.run("select.byPolygon", &json!({"points": points})).unwrap(), json!({"selected": 1})); assert_eq!(map_area_selection_rows(&s, first), [0]); } #[test] fn map_area_selection_drawn_row_policy_matches_rectangle_for_every_filter() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let operations = [ ("select.byRect", json!({"minLon": -1, "minLat": -1, "maxLon": 1, "maxLat": 1})), ("select.byPolygon", json!({"points": [[-1,-1],[1,-1],[1,1],[-1,1]]})), ("select.byRadius", json!({"lon": 0, "lat": 0, "radius": 100000})), ]; for restriction in [ "none", "invisible", "unselectable", "layer_min", "layer_max", "hidden_group", "group_min", "group_max", "overlapping_groups", "definition", "display", "time", "combined", "inactive_display", ] { let mut s = Session::new(Project::default()); let mut dataset = Dataset::new("Events", GeometryType::Point, vec![Field::new("n", FieldType::Integer), Field::new("when", FieldType::Text)]); for (row, date) in ["2025-01-15", "2025-02-15", "2025-03-15", "2025-02-20"].into_iter().enumerate() { let geometry = (row != 3).then(|| Geometry::Point(Coord::new(0.1 * (row + 1) as f64, 0.1))); dataset.push(geometry, vec![V::Int(row as i64), V::Text(date.into())]); } let id = s.map_mut().add_dataset(dataset); s.active_layer = Some(id); s.run("map.goToXY", &json!({"lon": 0, "lat": 0, "scale": 1000000})).unwrap(); let scale = s.scale(); match restriction { "invisible" => { s.run("layer.visibility", &json!({"layer": id, "visible": false})).unwrap(); } "unselectable" => { s.run("layer.selectable", &json!({"layer": id, "on": false})).unwrap(); } "layer_min" => { s.run("layer.scaleRange", &json!({"layer": id, "min": scale * 0.5})).unwrap(); } "layer_max" => { s.run("layer.scaleRange", &json!({"layer": id, "max": scale * 2.0})).unwrap(); } "hidden_group" | "group_min" | "group_max" | "overlapping_groups" => { let group = s.run("group.add", &json!({"name": "Limited", "layers": [id]})).unwrap()["group"].clone(); match restriction { "group_min" => { s.run("group.scaleRange", &json!({"group": group, "min": scale * 0.5})).unwrap(); } "group_max" => { s.run("group.scaleRange", &json!({"group": group, "max": scale * 2.0})).unwrap(); } _ => { s.run("group.set", &json!({"group": group, "visible": false})).unwrap(); } } if restriction == "overlapping_groups" { // held_by_group must win even when a second, visible membership exists. let mut visible = rustgis_map::LayerGroup::new("Also visible"); visible.id = 1000000; visible.children.push(id); s.map_mut().groups.push(visible); } } _ => {} } if matches!(restriction, "definition" | "combined") { s.run("layer.definitionQuery", &json!({"layer": id, "where": "n <> 0"})).unwrap(); } if matches!(restriction, "display" | "combined" | "inactive_display") { let filters = if restriction == "inactive_display" { json!([{"name": "Inactive", "where": "n = -1", "minScale": scale * 0.5}]) } else { json!([{"name": "Not March", "where": "n <> 2"}]) }; s.run("layer.displayFilters", &json!({"layer": id, "filters": filters})).unwrap(); } if matches!(restriction, "time" | "combined") { s.run("time.bind", &json!({"layer": id, "field": "when"})).unwrap(); s.run("time.set", &json!({"date": "2025-02-15", "unit": "month", "show": "step"})).unwrap(); } let expected = match restriction { "none" | "inactive_display" => vec![0, 1, 2], "definition" => vec![1, 2], "display" => vec![0, 1], "time" | "combined" => vec![1], _ => vec![], }; let mut rectangle = None; for (command, params) in &operations { // A new query clears even a previously selected geometry-less/filtered-out row. s.map_mut().layer_mut(id).unwrap().selection = vec![0, 1, 2, 3]; assert_eq!(s.run(command, params).unwrap(), json!({"selected": expected.len()}), "{restriction}: {command}"); let actual = map_area_selection_rows(&s, id); assert_eq!(actual, expected, "{restriction}: {command}"); if let Some(rectangle) = &rectangle { assert_eq!(&actual, rectangle, "{restriction}: {command} differs from rectangle"); } else { rectangle = Some(actual); } } } } #[test] fn map_area_selection_radius_typed_geometries_match_network_at_zero_and_sixty() { use rustgis_analysis::network::{radius_boundary_point, radius_distance_m, within}; use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::{Coord, lonlat_to_merc, merc_to_lonlat}; for latitude in [0.0_f64, 60.0] { let center = Coord::new(3.0, latitude); let origin = lonlat_to_merc(center); let k = 1.0 / latitude.to_radians().cos().max(0.01); let at = |x: f64, y: f64| Coord::new(origin.x + x * k, origin.y + y * k); let ring = |radius: f64| vec![at(-radius, -radius), at(radius, -radius), at(radius, radius), at(-radius, radius), at(-radius, -radius)]; let boundary = merc_to_lonlat(at(600.0, 800.0)); // Round-trip arithmetic at an exact boundary is part of within's contract. Derive the // query threshold with its shared metric, not an epsilon-expanded independent distance. let radius = radius_distance_m(center, boundary).unwrap(); assert!((radius - 1000.0).abs() < 1e-6, "{latitude}: {radius}"); assert!((radius_distance_m(center, merc_to_lonlat(at(300.0, 400.0))).unwrap() - 500.0).abs() < 1e-6); assert!(radius_distance_m(center, merc_to_lonlat(at(606.0, 808.0))).unwrap() > radius); let preview_boundary = radius_boundary_point(center, radius, 800.0_f64.atan2(600.0)).unwrap(); assert!((radius_distance_m(center, preview_boundary).unwrap() - radius).abs() < 1e-6); if latitude == 60.0 { let east = merc_to_lonlat(Coord::new(origin.x + 2000.0, origin.y)); assert!((radius_distance_m(center, east).unwrap() - 1000.0).abs() < 1e-6); } let fixtures = [ ( "Points", GeometryType::Point, vec![ Some(Geometry::Point(at(0.0, 0.0))), Some(Geometry::Point(at(300.0, 400.0))), Some(Geometry::Point(at(600.0, 800.0))), Some(Geometry::Point(at(606.0, 808.0))), Some(Geometry::MultiPoint(vec![at(2000.0, 2000.0), at(300.0, 400.0)])), None, ], vec![0, 1, 2, 4], ), ( "Lines", GeometryType::Polyline, vec![ Some(Geometry::Line(vec![vec![at(-2000.0, 0.0), at(2000.0, 0.0)]])), Some(Geometry::Line(vec![vec![at(600.0, 800.0), at(1200.0, 1600.0)]])), Some(Geometry::Line(vec![vec![at(2000.0, -2000.0), at(2000.0, 2000.0)]])), ], vec![0, 1], ), ( "Polygons", GeometryType::Polygon, vec![ Some(Geometry::Polygon(vec![vec![ring(2000.0)]])), Some(Geometry::Polygon(vec![vec![ring(3000.0), ring(2000.0)]])), Some(Geometry::Polygon(vec![vec![vec![at(600.0, 800.0), at(1600.0, 800.0), at(600.0, 1800.0), at(600.0, 800.0)]]])), Some(Geometry::Polygon(vec![vec![vec![ at(400.0, 400.0), at(500.0, 400.0), at(500.0, 500.0), at(400.0, 500.0), at(400.0, 400.0), ]]])), Some(Geometry::Polygon(vec![vec![vec![ at(2000.0, 2000.0), at(3000.0, 2000.0), at(3000.0, 3000.0), at(2000.0, 2000.0), ]]])), ], vec![0, 2, 3], ), ]; let mut s = Session::new(Project::default()); let mut expected_layers = Vec::new(); for (name, kind, geometries, expected) in fixtures { let geometries: Vec<_> = geometries.into_iter().map(|g| g.map(|g| g.map_coords(&merc_to_lonlat))).collect(); let id = map_area_selection_add(&mut s, name, kind, &geometries); let dataset = &s.map().layer(id).unwrap().dataset; let rows: Vec<_> = (0..dataset.features.len()).collect(); let mut direct: Vec<_> = within(dataset, &rows, center, radius).into_iter().map(|(row, _)| row).collect(); direct.sort_unstable(); assert_eq!(direct, expected, "{latitude}: {name} numerical fixture"); expected_layers.push((id, direct)); } // Radius hit testing must remain in its original dataset/metric space when the active // flat projection changes, including the regional grid used by the polygon fixture. for projection in ["mercator", "robinson", "EPSG:32631"] { s.run("map.projection", &json!({"type": projection})).unwrap(); let total: usize = expected_layers.iter().map(|(_, rows)| rows.len()).sum(); assert_eq!( s.run("select.byRadius", &json!({"lon": center.x, "lat": center.y, "radius": radius})).unwrap(), json!({"selected": total}), "{latitude}: {projection}" ); for (id, expected) in &expected_layers { assert_eq!(&map_area_selection_rows(&s, *id), expected, "{latitude}: {projection}: {id}"); } } } } #[test] fn map_area_selection_radius_retains_legacy_geographic_prefilter() { use rustgis_analysis::network::{radius_distance_m, within}; use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::Coord; let center = Coord::new(0.0, 60.0); let point = Coord::new(0.0, 59.1); let distance = radius_distance_m(center, point).unwrap(); assert!(distance > 98000.0 && distance < 100000.0, "{distance}"); let mut s = Session::new(Project::default()); let id = map_area_selection_add(&mut s, "Legacy boundary", GeometryType::Point, &[Some(Geometry::Point(point))]); assert!(within(&s.map().layer(id).unwrap().dataset, &[0], center, 100000.0).is_empty()); for mode in ["add", "remove", "new"] { s.map_mut().layer_mut(id).unwrap().selection = vec![0]; assert_eq!( s.run("select.byRadius", &json!({"lon": 0, "lat": 60, "radius": 100000, "mode": mode})).unwrap(), json!({"selected": 0}) ); assert_eq!(map_area_selection_rows(&s, id), if mode == "new" { vec![] } else { vec![0] }, "{mode}"); } } #[test] fn map_area_selection_polygon_uses_projected_crossings_holes_and_multipart_features() { use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::Coord; for projection in ["mercator", "robinson", "EPSG:32631"] { let mut s = Session::new(Project::default()); s.run("map.projection", &json!({"type": projection})).unwrap(); let flat = s.map().flat(); let origin = flat.forward_checked(Coord::new(3.0, 50.0)).unwrap(); let at = |x: f64, y: f64| Coord::new(origin.x + x * 10000.0, origin.y + y * 10000.0); let ring = |a: f64, b: f64| vec![at(a, a), at(b, a), at(b, b), at(a, b), at(a, a)]; let fixtures = [ ( "Projected points", GeometryType::Point, vec![ Some(Geometry::Point(at(0.5, 0.5))), Some(Geometry::Point(at(2.0, 2.0))), Some(Geometry::Point(at(2.5, 0.5))), Some(Geometry::Point(at(4.0, 4.0))), Some(Geometry::MultiPoint(vec![at(2.0, 2.0), at(0.5, 2.0)])), None, ], vec![0, 2, 4], ), ( "Projected lines", GeometryType::Polyline, vec![ // Neither endpoint is inside the L, but the segment traverses it. Some(Geometry::Line(vec![vec![at(-1.0, 0.5), at(4.0, 0.5)]])), Some(Geometry::Line(vec![vec![at(1.5, 2.0), at(2.5, 2.0)]])), Some(Geometry::Line(vec![vec![at(4.0, 4.0), at(5.0, 5.0)], vec![at(0.2, 0.2), at(0.8, 0.8)]])), ], vec![0, 2], ), ( "Projected polygons", GeometryType::Polygon, vec![ // The mask lies entirely inside this exterior, with no boundary crossings. Some(Geometry::Polygon(vec![vec![ring(-1.0, 5.0)]])), Some(Geometry::Polygon(vec![vec![ring(-1.0, 5.0), ring(-0.5, 3.5)]])), Some(Geometry::Polygon(vec![vec![ring(1.5, 2.5)]])), Some(Geometry::Polygon(vec![vec![ring(1.5, 2.5)], vec![ring(0.2, 0.8)]])), ], vec![0, 3], ), ]; let mut expected_layers = Vec::new(); for (name, kind, geometries, expected) in fixtures { let geometries: Vec<_> = geometries.into_iter().map(|g| g.map(|g| g.map_coords(&|p| flat.inverse(p).unwrap()))).collect(); let id = map_area_selection_add(&mut s, name, kind, &geometries); expected_layers.push((id, expected)); } let points: Vec<_> = [(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (1.0, 1.0), (1.0, 3.0), (0.0, 3.0)] .into_iter() .map(|(x, y)| { let p = flat.inverse(at(x, y)).unwrap(); [p.x, p.y] }) .collect(); assert_eq!(s.run("select.byPolygon", &json!({"points": points})).unwrap(), json!({"selected": 7}), "{projection}"); for (id, expected) in expected_layers { assert_eq!(map_area_selection_rows(&s, id), expected, "{projection}: {id}"); } } } #[test] fn zooms_to_a_layer_follow_its_definition_query() { let mut s = world(); s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "continent = 'South America'"})).unwrap(); let in_south_america = |c: rustgis_geom::Coord| (-85.0..-30.0).contains(&c.x) && (-60.0..15.0).contains(&c.y); // The frame zooms to the countries drawn, not the whole dataset (#206). s.run("layout.new", &json!({"page": "a4", "orientation": "portrait", "template": "map"})).unwrap(); s.run("layout.frameZoom", &json!({"to": "layer", "layer": "Countries"})).unwrap(); let Some(rustgis_map::layout::ElementKind::MapFrame { center, scale, .. }) = s.project.layouts[0].first_frame().and_then(|id| s.project.layouts[0].element(id)).map(|e| e.kind.clone()) else { panic!("no frame") }; assert!(in_south_america(center), "{center:?}"); assert!(scale < 150_000_000.0, "{scale}"); // So does the map view. s.run("layer.zoomTo", &json!({"layer": "Countries"})).unwrap(); assert!(in_south_america(s.map().center_lonlat()), "{:?}", s.map().center_lonlat()); // A query no country passes leaves nothing to zoom to. s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "continent = 'Atlantis'"})).unwrap(); assert!(s.run("layer.zoomTo", &json!({"layer": "Countries"})).is_err()); assert!(s.run("layout.frameZoom", &json!({"to": "layer", "layer": "Countries"})).is_err()); } #[test] fn layout_frames_print_site_plans_and_finds() { // Excavation plans at 1:20, finds at 1:1 and larger (#902): no longer held at 1:100. use rustgis_map::layout::{ElementKind, MIN_FRAME_SCALE}; let mut s = world(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let scale_of = |s: &Session| { let l = &s.project.layouts[0]; match l.element(l.first_frame().unwrap_or_default()).map(|e| &e.kind) { Some(ElementKind::MapFrame { scale, .. }) => *scale, _ => panic!("no frame"), } }; let id = s.project.layouts[0].first_frame().unwrap(); s.run("layout.update", &json!({"element": id, "scale": 20})).unwrap(); assert_eq!(scale_of(&s), 20.0); s.run("layout.update", &json!({"element": id, "scale": 0.1})).unwrap(); assert_eq!(scale_of(&s), MIN_FRAME_SCALE, "no larger than 2:1"); // A frame matched to a view at 1:1 is a site-scale frame too. s.run("map.goToXY", &json!({"lon": 10.746, "lat": 46.82, "scale": 1})).unwrap(); s.run("layout.frameZoom", &json!({"to": "view"})).unwrap(); assert!(scale_of(&s) < 100.0, "{}", scale_of(&s)); } #[test] fn layout_frames_turn_and_tilt() { use rustgis_map::layout::ElementKind; let mut s = world(); let frame_of = |s: &Session| { let l = &s.project.layouts[0]; let id = l.first_frame().unwrap_or_default(); match l.element(id).map(|e| e.kind.clone()) { Some(ElementKind::MapFrame { bearing, pitch, .. }) => (id, bearing, pitch), _ => panic!("no frame"), } }; // A new frame shows the map view as it is, turned and tilted. s.run("map.setBearing", &json!({"bearing": 30})).unwrap(); s.run("map.setPitch", &json!({"pitch": 50})).unwrap(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let (id, bearing, pitch) = frame_of(&s); assert_eq!((bearing, pitch), (30.0, 50.0)); // Set on the frame, kept in range; a value that isn't a number changes nothing. s.run("layout.update", &json!({"element": id, "bearing": 400, "pitch": 200})).unwrap(); assert_eq!(frame_of(&s), (id, 40.0, 85.0)); assert!(s.run("layout.update", &json!({"element": id, "pitch": "steep", "bearing": 10})).is_err()); assert_eq!(frame_of(&s), (id, 40.0, 85.0)); // Zooming the frame to the view takes the view's bearing and pitch too. s.run("map.resetNorth", &json!({})).unwrap(); s.run("layout.frameZoom", &json!({"to": "view"})).unwrap(); assert_eq!(frame_of(&s), (id, 0.0, 0.0)); // A flat frame saves as before; a tilted one draws differently, with its north arrow turned. let flat_json = serde_json::to_string(&s.project.layouts[0]).unwrap_or_default(); assert!(!flat_json.contains("bearing") && !flat_json.contains("pitch"), "{flat_json}"); s.run("layout.addElement", &json!({"kind": "northArrow", "frame": id})).unwrap(); let info = rustgis_render::layout::PageInfo::default(); let draw = |s: &mut Session| { rustgis_render::layout::render(&s.project.layouts[0].clone(), &mut s.project.maps, 48.0, &info).map(|p| p.data().to_vec()) }; let flat = draw(&mut s).expect("drawn"); s.run("layout.update", &json!({"element": id, "bearing": 90, "pitch": 60})).unwrap(); let tilted = draw(&mut s).expect("drawn"); assert_ne!(flat, tilted); let saved = serde_json::to_string(&s.project.layouts[0]).unwrap_or_default(); assert!(saved.contains("\"bearing\":90") && saved.contains("\"pitch\":60"), "{saved}"); } #[test] fn terrain_is_set_from_a_dem_or_tiles() { let mut s = world(); // A source is required, and must be a raster layer or an XYZ URL. assert!(s.run("map.terrain", &json!({})).is_err()); assert!(s.run("map.terrain", &json!({"layer": "Countries"})).is_err()); assert!(s.run("map.terrain", &json!({"layer": "Nowhere"})).is_err()); assert!(s.run("map.terrain", &json!({"tiles": "https://example.com/dem.png"})).is_err()); assert!(s.run("map.terrain", &json!({"tiles": "file:///etc/{z}/{x}/{y}.png"})).is_err()); assert!(s.run("map.terrain", &json!({"tiles": "https://e.com/{z}/{x}/{y}.png", "encoding": "jpeg"})).is_err()); assert!(s.map().view.terrain.is_none()); // Tiles, then only a new exaggeration (kept in range), then off. let v = s.run("map.terrain", &json!({"tiles": "https://e.com/{z}/{x}/{y}.png", "encoding": "mapbox", "exaggeration": 2})).unwrap(); assert_eq!(v["source"]["encoding"], "mapbox"); assert_eq!(v["exaggeration"], 2.0); let v = s.run("map.terrain", &json!({"exaggeration": 400})).unwrap(); assert_eq!(v["exaggeration"], rustgis_map::view::MAX_EXAGGERATION); assert_eq!(v["source"]["tiles"], "https://e.com/{z}/{x}/{y}.png"); assert!(s.run("map.terrain", &json!({"exaggeration": "steep"})).is_err()); // Saved with the project. let saved = serde_json::to_string(&s.map().view).unwrap_or_default(); let back: rustgis_map::view::ViewSettings = serde_json::from_str(&saved).unwrap_or_default(); assert_eq!(back.terrain, s.map().view.terrain); s.run("map.terrain", &json!({"off": true})).unwrap(); assert!(s.map().view.terrain.is_none()); // Nothing named: the active layer, when it is a raster, is the DEM; a vector one is refused, // saying to make a DEM the active layer. s.active_layer = s.map().layer_by_name("Countries").map(|l| l.id); assert!(s.run("map.terrain", &json!({})).unwrap_err().contains("make a DEM raster layer the active layer")); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap(); let (id, name) = (dem.id, dem.name.clone()); s.active_layer = Some(id); let v = s.run("map.terrain", &json!({"exaggeration": 3})).unwrap(); assert_eq!((v["source"]["layer"].as_str(), v["exaggeration"].as_f64()), (Some(name.as_str()), Some(3.0))); // On a map looked at straight down, a notice names the layer picked and says to tilt it. let said = s.notices.last().unwrap().text.clone(); assert!(said.contains(&format!("heights from '{name}'")) && said.contains("tilt the map"), "{said}"); // A change of exaggeration alone says nothing again. let mark = s.notice_mark(); s.run("map.terrain", &json!({"exaggeration": 2})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("Terrain is on")), "{:?}", s.notices_since(mark)); // Two layers of the DEM's name: refused, since the terrain keeps it by name. s.run("map.terrain", &json!({"off": true})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let second = s.map().layers.iter().filter(|l| l.is_raster()).map(|l| l.id).max().unwrap(); s.active_layer = Some(second); s.run("layer.rename", &json!({"name": name})).unwrap(); let e = s.run("map.terrain", &json!({})).unwrap_err(); assert!(e.contains("more than one layer is called"), "{e}"); s.run("layer.rename", &json!({"name": "dem 2"})).unwrap(); s.active_layer = Some(id); s.run("map.terrain", &json!({})).unwrap(); // A project saved with a terrain on a shared DEM name says so as it opens. { let mut saved = s.project.clone(); let m = &mut saved.maps[0]; let first = m.layers.iter().find(|l| l.is_raster()).map(|l| l.name.clone()).unwrap(); if let Some(l) = m.layers.iter_mut().filter(|l| l.is_raster()).nth(1) { l.name = first.clone(); } m.view.terrain = Some(rustgis_map::view::Terrain { source: rustgis_map::view::TerrainSource::Layer(first), exaggeration: 1.0 }); let mut opened = Session::new(Project::default()); crate::cmd::project::reset_to(&mut opened, saved); assert!(opened.notices.iter().any(|n| n.text.contains("is shared by two layers")), "{:?}", opened.notices); } // Renaming another layer leaves the terrain as it is. s.run("layer.rename", &json!({"layer": "Countries", "name": "Paises"})).unwrap(); assert!(s.map().view.terrain.is_some()); s.run("layer.rename", &json!({"layer": "Paises", "name": "Countries"})).unwrap(); // Renaming the DEM onto another layer's name turns the terrain off, said. s.active_layer = Some(id); s.run("layer.rename", &json!({"name": "dem 2"})).unwrap(); assert!(s.map().view.terrain.is_none()); assert!(s.notices.last().unwrap().text.contains("two layers are now called 'dem 2'"), "{:?}", s.notices.last()); s.run("layer.rename", &json!({"name": name})).unwrap(); s.run("map.terrain", &json!({"layer": name})).unwrap(); // Removing the DEM turns the terrain off, said. { let mut t = Session::new(Project::default()); t.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); t.run("map.terrain", &json!({})).unwrap(); t.run("layer.remove", &json!({})).unwrap(); assert!(t.map().view.terrain.is_none()); assert!(t.notices.last().unwrap().text.contains("was removed"), "{:?}", t.notices.last()); // A mobile map package keeps a hidden DEM, which still gives the heights. t.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); t.run("map.terrain", &json!({})).unwrap(); t.run("layer.visibility", &json!({})).unwrap(); assert!(t.map().layers.iter().any(|l| l.is_raster() && !l.visible)); let pkg = std::env::temp_dir().join(format!("rustgis-terrain-{}.rgismpk", std::process::id())); t.run("map.package", &json!({"path": pkg.display().to_string(), "mobile": true})).unwrap(); t.run("map.import", &json!({"path": pkg.display().to_string()})).unwrap(); let _ = std::fs::remove_file(&pkg); let packaged = t.project.maps.last().unwrap(); assert!(packaged.layers.iter().any(|l| l.is_raster()), "the hidden DEM is in the package"); assert!(packaged.view.terrain.is_some()); t.run("layer.remove", &json!({"layer": t.map().layers.iter().find(|l| l.is_raster()).map(|l| l.id).unwrap()})).unwrap(); // So does removing a group that holds it. t.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = t.map().layers.iter().find(|l| l.is_raster()).map(|l| l.id).unwrap(); let group = t.run("group.add", &json!({"name": "Relevo", "layers": [dem]})).unwrap()["group"].clone(); t.active_layer = Some(dem); t.run("map.terrain", &json!({})).unwrap(); t.run("group.remove", &json!({"group": group})).unwrap(); assert!(t.map().view.terrain.is_none()); assert!(t.notices.last().unwrap().text.contains("was removed"), "{:?}", t.notices.last()); } // Renaming the DEM keeps the terrain on it. s.run("layer.rename", &json!({"name": "Relevo"})).unwrap(); assert_eq!(s.run("map.terrain", &json!({"exaggeration": 1})).unwrap()["source"]["layer"], "Relevo"); // A second layer renamed to the DEM's name: the terrain, now ambiguous, is turned off, and // said. s.active_layer = Some(second); s.run("layer.rename", &json!({"name": "relevo"})).unwrap(); assert!(s.map().view.terrain.is_none()); assert!(s.notices.last().unwrap().text.contains("Terrain turned off: two layers are now called"), "{:?}", s.notices.last()); s.run("layer.rename", &json!({"name": "dem 2"})).unwrap(); s.active_layer = Some(id); s.run("map.terrain", &json!({"layer": "Relevo"})).unwrap(); let name = "Relevo".to_string(); // Once on, nothing named changes nothing but what is given: the active layer doesn't replace it. s.active_layer = s.map().layer_by_name("Countries").map(|l| l.id); let v = s.run("map.terrain", &json!({})).unwrap(); assert_eq!(v["source"]["layer"].as_str(), Some(name.as_str())); s.run("map.terrain", &json!({"off": true})).unwrap(); } #[test] fn graduated_symbology_skips_a_current_text_field() { let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated"})).unwrap(); let layer = s.map().layer_by_name("Countries").unwrap(); let rustgis_map::Renderer::Graduated { field, .. } = &layer.renderer else { panic!("Graduated Colors renderer expected") }; assert_ne!(field, "income_grp", "the text field from the previous renderer is not reused"); assert!(layer.dataset.field_index(field).and_then(|i| layer.dataset.fields.get(i)).is_some_and(|f| f.ty.is_numeric())); } #[test] fn symbology_chooses_fields_by_renderer_requirements() { let mut s = Session::new(Project::default()); s.run( "layer.addDataText", &json!({ "fileName": "text-fields.geojson", "text": r#"{"type":"FeatureCollection","features":[ {"type":"Feature","properties":{"g_id":"61259","g_name":"Liezen"},"geometry":{"type":"Point","coordinates":[14.24,47.57]}}, {"type":"Feature","properties":{"g_id":"61260","g_name":"Graz"},"geometry":{"type":"Point","coordinates":[15.44,47.07]}} ]}"# }), ) .unwrap(); s.run("layer.symbology", &json!({"layer": "text-fields", "type": "unique"})).unwrap(); let rustgis_map::Renderer::Unique { field, classes, .. } = &s.map().layer_by_name("text-fields").unwrap().renderer else { panic!("Unique Values renderer expected") }; assert_eq!(field, "g_id"); assert_eq!(classes.len(), 2); let before = s.map().layer_by_name("text-fields").unwrap().renderer.clone(); let depth = s.undo_depth(); let err = s.run("layer.symbology", &json!({"layer": "text-fields", "type": "graduated"})).unwrap_err(); assert_eq!(err, "no numeric field is available for this symbology"); assert_eq!(s.map().layer_by_name("text-fields").unwrap().renderer, before, "failed mode switch leaves current styling intact"); assert_eq!(s.undo_depth(), depth, "failed mode switch adds no undo step"); } #[test] fn symbology_classes_follow_the_definition_query() { let mut s = world(); s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "continent = 'Europe'"})).unwrap(); let shown = |s: &Session, field: &str| -> Vec { let l = s.map().layer_by_name("Countries").unwrap(); let fi = l.dataset.field_index(field).unwrap(); l.visible_rows().iter().enumerate().filter(|(_, v)| **v).map(|(r, _)| l.dataset.value(r, fi).clone()).collect() }; // The top class ends at the largest country drawn, not at China (#236). s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap(); let top = shown(&s, "pop_est").iter().filter_map(|v| v.as_f64()).fold(f64::MIN, f64::max); let rustgis_map::Renderer::Graduated { breaks, .. } = &s.map().layer_by_name("Countries").unwrap().renderer else { panic!("graduated renderer expected") }; assert_eq!(breaks.last().copied(), Some(top)); // Unique values list only the values drawn. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); let mut drawn: Vec = shown(&s, "income_grp").iter().map(|v| v.to_string()).collect(); drawn.sort(); drawn.dedup(); let rustgis_map::Renderer::Unique { classes, .. } = &s.map().layer_by_name("Countries").unwrap().renderer else { panic!("unique renderer expected") }; assert_eq!(classes.iter().map(|(k, _)| k.clone()).collect::>(), drawn); // Classes are taken once: changing the query keeps them. let before = s.map().layer_by_name("Countries").unwrap().renderer.clone(); s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": ""})).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer, before); // A query no country passes leaves nothing to class. s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "continent = 'Atlantis'"})).unwrap(); let err = s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est"})).unwrap_err(); assert!(err.contains("definition query"), "{err}"); } #[test] fn layer_class_style_edits_one_class() { // #314: one class's colour and legend label, by command (the Symbology pane runs the same). let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); let renderer = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.clone(); let value = renderer(&s).legend().into_iter().next().unwrap().label; // Legend rows are sorted by label (#218), so a renamed class moves: find its row from its // value, not its label, so an edit landing on another class can't pass. let class = |s: &Session| { let r = renderer(s); let rustgis_map::Renderer::Unique { classes, .. } = &r else { panic!("unique renderer expected") }; let i = classes.iter().position(|(k, _)| *k == value).unwrap(); let row = r.class_order().into_iter().position(|o| o == i).unwrap(); r.legend().into_iter().nth(row).unwrap() }; let r = s.run("layer.classStyle", &json!({"layer": "Countries", "value": value, "color": "#123456", "label": "Rich"})).unwrap(); assert_eq!(r["label"], "Rich"); assert!(r["color"].as_str().is_some_and(|c| c.starts_with("#123456")), "{r}"); assert_eq!(class(&s).label, "Rich"); // The renamed class sorts to a new legend row and keeps its own colour there. assert!(class(&s).symbol.fill.hex().starts_with("#123456"), "{:?}", class(&s)); // One undo step, as every command is. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(class(&s).label, value); s.run("edit.redo", &Value::Null).unwrap(); let edited = class(&s); assert_eq!(edited.label, "Rich"); // Applying the symbology again on the same field keeps the edit; a new scheme recolours // every class and keeps the label. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); assert_eq!(class(&s), edited); s.run("layer.ramp", &json!({"layer": "Countries", "ramp": "Ember"})).unwrap(); assert_eq!(class(&s).label, "Rich"); assert_ne!(class(&s).symbol, edited.symbol); // The fallback class. s.run("layer.classStyle", &json!({"layer": "Countries", "other": true, "label": "Rest"})).unwrap(); assert_eq!(renderer(&s).legend().last().map(|e| e.label.clone()).as_deref(), Some("Rest")); // Refused: no such class, the wrong way to name one, two at once, or nothing to change. for bad in [ json!({"layer": "Countries", "value": "Atlantis", "color": "#000000"}), json!({"layer": "Countries", "index": 0, "color": "#000000"}), json!({"layer": "Countries", "value": value, "other": true, "label": "x"}), json!({"layer": "Countries", "value": value}), json!({"layer": "Countries", "value": value, "color": "red"}), json!({"layer": "Countries", "value": value, "color": "#000000", "label": 5}), json!({"layer": "Countries", "value": value, "label": null}), json!({"layer": "Countries", "value": value, "label": "x", "color": 5}), json!({"layer": "Countries", "value": value, "label": "x", "outline": true}), ] { assert!(s.run("layer.classStyle", &bad).is_err(), "{bad}"); } // A malformed index says what is wrong with it. for index in [json!(0.5), json!(-1), json!("first")] { let err = s.run("layer.classStyle", &json!({"layer": "Countries", "index": index, "label": "x"})).unwrap_err(); assert!(err.contains("whole number"), "{index}: {err}"); } // A numeric value names its class as the data prints it, whether sent as an integer or not. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "pop_est"})).unwrap(); // A whole value among the first classes (the smallest, as they're in numeric order). let rustgis_map::Renderer::Unique { classes, .. } = renderer(&s) else { panic!("unique renderer expected") }; let whole = classes.iter().find_map(|(k, _)| k.parse::().ok().filter(|n| *n > 1000)).unwrap(); for v in [json!(whole), json!(whole as f64)] { let r = s.run("layer.classStyle", &json!({"layer": "Countries", "value": v, "label": "Brazil"})).unwrap(); assert_eq!(r["label"], "Brazil", "{v}"); } // Graduated Colors, by number: the label replaces the range. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 3})).unwrap(); s.run("layer.classStyle", &json!({"layer": "Countries", "index": 2, "label": "Most populous"})).unwrap(); assert_eq!(renderer(&s).legend().get(2).map(|e| e.label.clone()).as_deref(), Some("Most populous")); assert!(s.run("layer.classStyle", &json!({"layer": "Countries", "index": 3, "label": "x"})).is_err()); } /// Read classes by their command identity, not their sorted legend position. fn class_style_rows(s: &Session, layer: &str) -> Vec<(Value, rustgis_map::Symbol)> { match &s.map().layer_by_name(layer).unwrap().renderer { rustgis_map::Renderer::Graduated { symbols, .. } => { symbols.iter().enumerate().map(|(i, symbol)| (json!({"layer": layer, "index": i}), *symbol)).collect() } rustgis_map::Renderer::Unique { classes, other, .. } => classes .iter() .map(|(value, symbol)| (json!({"layer": layer, "value": value}), *symbol)) .chain(std::iter::once((json!({"layer": layer, "other": true}), *other))) .collect(), _ => panic!("class renderer expected"), } } #[test] fn layer_class_style_polygon_alpha_targets_only_the_named_class() { use rustgis_map::Rgba; for (kind, field) in [("graduated", "pop_est"), ("unique", "income_grp")] { let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field, "classes": 3})).unwrap(); let rows = class_style_rows(&s, "Countries"); // A middle graduated class, a unique value, and the unique fallback all use the same rules. let targets = if kind == "unique" { vec![1, rows.len() - 1] } else { vec![1] }; for target in targets { for (color, alpha) in [ ("#123456", 170), (" #123456FF ", 255), ("12345600", 0), ("#12345640", 64), ("#123456", 150), ("#123456C8", 200), (" 123456 ", 200), ] { let before = class_style_rows(&s, "Countries"); let mut params = before[target].0.clone(); params["color"] = json!(color); let result = s.run("layer.classStyle", ¶ms).unwrap(); let after = class_style_rows(&s, "Countries"); let fill = Rgba::rgb(0x12, 0x34, 0x56).with_alpha(alpha); assert_eq!(after[target].1.fill, fill, "{kind}: {params}"); assert_eq!(Rgba::from_hex(result["color"].as_str().unwrap()), Some(fill)); for (i, (old, new)) in before.iter().zip(&after).enumerate() { assert_eq!(new.0, old.0); if i != target { assert_eq!(new.1, old.1, "{kind}: {params} changed class {i}"); } } if alpha == 0 { s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(class_style_rows(&s, "Countries"), before); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(class_style_rows(&s, "Countries"), after); let saved = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(Project::from_json(&saved).unwrap()); assert_eq!(class_style_rows(&reopened, "Countries"), after); } } } } } #[test] fn layer_class_alpha_survives_reclassification_and_ramp() { for (kind, field, scheme, next_scheme) in [("graduated", "pop_est", "Ember", "Harbour"), ("unique", "income_grp", "Harbour", "Ember")] { let mut s = world(); let mut symbology = json!({"layer": "Countries", "type": kind, "field": field, "classes": 3, "ramp": scheme}); s.run("layer.symbology", &symbology).unwrap(); let original = class_style_rows(&s, "Countries"); for (i, (mut params, symbol)) in original.iter().cloned().enumerate() { // Keep exactly the ramp's RGB: an alpha-only edit must not be mistaken for an untouched fill. params["color"] = json!(symbol.fill.with_alpha([0, 64, 255][i % 3]).hex8()); s.run("layer.classStyle", ¶ms).unwrap(); } let edited = s.map().layer_by_name("Countries").unwrap().renderer.clone(); let before = class_style_rows(&s, "Countries"); for (i, ((_, old), (_, new))) in original.iter().zip(&before).enumerate() { assert_eq!((new.fill.r(), new.fill.g(), new.fill.b()), (old.fill.r(), old.fill.g(), old.fill.b())); assert_eq!(new.fill.a(), [0, 64, 255][i % 3]); } // Both an omitted scheme and an explicit unchanged scheme are reapplications, not resets. let mut same = symbology.clone(); same.as_object_mut().unwrap().remove("ramp"); for params in [&same, &symbology] { s.run("layer.symbology", params).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer, edited, "{kind}"); } // Recolouring through layer.ramp keeps every class's exact alpha, including the fallback's. s.run("layer.ramp", &json!({"layer": "Countries", "ramp": next_scheme})).unwrap(); let recolored = class_style_rows(&s, "Countries"); for ((old_target, old), (new_target, new)) in before.iter().zip(&recolored) { assert_eq!(new_target, old_target); assert_eq!(new.fill.a(), old.fill.a(), "{kind}: {new_target}"); } assert!( before .iter() .zip(&recolored) .any(|((_, old), (_, new))| { (old.fill.r(), old.fill.g(), old.fill.b()) != (new.fill.r(), new.fill.g(), new.fill.b()) }) ); // A different scheme chosen through layer.symbology still resets polygon opacity as before. symbology["ramp"] = json!(scheme); s.run("layer.symbology", &symbology).unwrap(); assert!(class_style_rows(&s, "Countries").iter().all(|(_, symbol)| symbol.fill.a() == 170), "{kind}"); } } #[test] fn layer_class_creation_keeps_explicit_layer_fill_alpha() { for (kind, field) in [("graduated", "pop_est"), ("unique", "income_grp")] { for alpha in [0, 64, 255] { let mut s = world(); s.run("layer.color", &json!({"layer": "Countries", "color": format!("#336699{alpha:02X}")})).unwrap(); s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field, "classes": 3})).unwrap(); for (target, symbol) in class_style_rows(&s, "Countries") { assert_eq!(symbol.fill.a(), alpha, "{kind}: {target}"); } } } } #[test] fn layer_class_style_point_and_line_colors_keep_their_supplied_alpha() { use rustgis_map::Rgba; let mut s = world(); for layer in ["Cities", "Rivers"] { let field = s.map().layer_by_name(layer).unwrap().dataset.fields[0].name.clone(); s.run("layer.symbology", &json!({"layer": layer, "type": "unique", "field": field})).unwrap(); let mut params = class_style_rows(&s, layer)[0].0.clone(); for color in ["#12345600", "#12345640", "#123456FF", "#123456"] { let before = class_style_rows(&s, layer); params["color"] = json!(color); s.run("layer.classStyle", ¶ms).unwrap(); let after = class_style_rows(&s, layer); let fill = Rgba::from_hex(color).unwrap(); assert_eq!(after[0].1.fill, fill, "{layer}: {color}"); assert_eq!(after[0].1.stroke, if layer == "Rivers" { fill } else { before[0].1.stroke }); assert_eq!(&after[1..], &before[1..], "{layer}: other classes are untouched"); } } } #[test] fn layer_class_style_polygon_alpha_reaches_exported_pixels() { isolate_plugins(); let fixture = json!({"type": "FeatureCollection", "features": [{ "type": "Feature", "properties": {"value": 1}, "geometry": {"type": "Polygon", "coordinates": [[[-0.25, -0.25], [0.25, -0.25], [0.25, 0.25], [-0.25, 0.25], [-0.25, -0.25]]]} }]}); for kind in ["graduated", "unique"] { let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "square.geojson", "text": fixture.to_string()})).unwrap(); s.run("layer.symbology", &json!({"layer": "square", "type": kind, "field": "value", "classes": 1})).unwrap(); s.view_size = (128.0, 128.0); s.map_mut().basemap = rustgis_map::Basemap::Blank; s.map_mut().graticule = false; s.map_mut().camera = rustgis_map::Camera { center: rustgis_geom::Coord::new(0.0, 0.0), res: 1000.0, bearing: 0.0, pitch: 0.0 }; let mut params = class_style_rows(&s, "square")[0].0.clone(); for alpha in [255, 0, 64] { params["color"] = json!(format!("#FF0000{alpha:02X}")); s.run("layer.classStyle", ¶ms).unwrap(); let png = crate::cmd::share::map_png(&mut s, Some(128.0), Some(128.0), None).unwrap(); let pixels = crate::render::decode_png(&png).unwrap(); let center = pixels.pixel(64, 64).unwrap(); let background = pixels.pixel(0, 0).unwrap(); assert_eq!(center.alpha(), 255, "the export composites onto the blank basemap"); for (actual, source, behind) in [(center.red(), 255, background.red()), (center.green(), 0, background.green()), (center.blue(), 0, background.blue())] { let expected = (source * alpha + u32::from(behind) * (255 - alpha) + 127) / 255; assert!((i64::from(actual) - i64::from(expected)).abs() <= 1, "{kind}, alpha {alpha}: {center:?} over {background:?}"); } } } } #[test] fn layout_legends_list_what_the_frame_draws() { // #207's repro: classes taken before the query (or from a style) name values no drawn country has. let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); s.run("layer.definitionQuery", &json!({"layer": "Countries", "where": "continent = 'South America'"})).unwrap(); let l = s.map().layer_by_name("Countries").unwrap(); let rustgis_map::Renderer::Unique { classes, .. } = &l.renderer else { panic!("unique renderer expected") }; assert!(classes.len() > 2, "{classes:?}"); let shown: Vec = l.shown_legend(4e7).unwrap().into_iter().map(|e| e.label).collect(); let mut drawn: Vec = { let fi = l.dataset.field_index("income_grp").unwrap(); l.visible_rows().iter().enumerate().filter(|(_, v)| **v).map(|(r, _)| l.dataset.value(r, fi).to_string()).collect() }; drawn.sort(); drawn.dedup(); let mut sorted = shown.clone(); sorted.sort(); assert_eq!(sorted, drawn, "only the classes South America has, and no "); assert!(shown.len() < classes.len()); // The legend element lists what its frame draws unless told to list every class. s.run("layout.new", &json!({"page": "a4", "orientation": "portrait", "template": "map"})).unwrap(); let legend = |s: &Session| s.project.layouts[0].elements.iter().find(|e| e.kind.code() == "legend").map(|e| (e.id, e.kind.clone())).unwrap(); let (id, kind) = legend(&s); assert!(matches!(kind, rustgis_map::layout::ElementKind::Legend { all_classes: false, .. }), "{kind:?}"); s.run("layout.update", &json!({"element": id, "allClasses": true})).unwrap(); assert!(matches!(legend(&s).1, rustgis_map::layout::ElementKind::Legend { all_classes: true, hide_other: false, .. })); // can be left out on its own, in either mode. s.run("layout.update", &json!({"element": id, "hideOther": true})).unwrap(); assert!(matches!(legend(&s).1, rustgis_map::layout::ElementKind::Legend { all_classes: true, hide_other: true, .. })); // Read like every other on/off parameter (`b`): a value that is neither a bool, 1 or 0 nor // text keeps the setting, and text other than true/yes/1/on turns it off. s.run("layout.update", &json!({"element": id, "hideOther": 5})).unwrap(); assert!(matches!(legend(&s).1, rustgis_map::layout::ElementKind::Legend { hide_other: true, .. }), "a number keeps the setting"); s.run("layout.update", &json!({"element": id, "hideOther": "off"})).unwrap(); assert!(matches!(legend(&s).1, rustgis_map::layout::ElementKind::Legend { hide_other: false, .. })); let r = s.run("layout.addElement", &json!({"kind": "legend", "allClasses": true})).unwrap(); let added = r["element"].as_u64().unwrap() as u32; let kind = s.project.layouts[0].element(added).map(|e| e.kind.clone()).unwrap(); assert!(matches!(kind, rustgis_map::layout::ElementKind::Legend { all_classes: true, .. }), "{kind:?}"); // Layouts saved before the option open with it off. let old: rustgis_map::layout::ElementKind = serde_json::from_str(r#"{"Legend":{"frame":1,"title":"L"}}"#).unwrap(); assert!(matches!(old, rustgis_map::layout::ElementKind::Legend { all_classes: false, hide_other: false, .. })); } /// A map frame's grid can be a measured grid (#1008): eastings and northings in a projected /// system its step apart in metres, with whole or short labels; a system that isn't projected, or /// isn't one, is refused; a change of type starts its step afresh. #[test] fn a_frames_grid_can_be_a_measured_grid() { use rustgis_map::layout::{ElementKind, GridKind}; let mut s = world(); s.run("layout.new", &json!({"page": "a4", "template": "map"})).unwrap(); let frame = s.project.layouts[0].elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap().id; let grid = |s: &Session| match &s.project.layouts[0].element(frame).unwrap().kind { ElementKind::MapFrame { grid, .. } => grid.clone(), k => panic!("{k:?}"), }; s.run("layout.addElement", &json!({"kind": "grid", "step": 15})).unwrap(); assert_eq!(grid(&s).unwrap().step, 15.0); // Measured: the step in metres (a graticule's 15° isn't carried over), the system named. s.run("layout.update", &json!({"element": frame, "grid": {"type": "measured", "crs": " EPSG:25832 "}})).unwrap(); let g = grid(&s).unwrap(); assert_eq!((g.kind, g.step, g.crs.as_deref(), g.short), (GridKind::Measured, 0.0, Some("EPSG:25832"), false)); s.run("layout.update", &json!({"element": frame, "grid": {"step": 1000, "short": true}})).unwrap(); let g = grid(&s).unwrap(); assert_eq!((g.step, g.short), (1000.0, true)); // Steps in metres: from a metre to 10,000 km, 0 picks one; past 90 isn't a graticule's limit. for (step, want) in [(0.25, 1.0), (1e12, rustgis_map::layout::MAX_MEASURED_STEP), (-5.0, 0.0), (250_000.0, 250_000.0)] { s.run("layout.update", &json!({"element": frame, "grid": {"step": step}})).unwrap(); assert_eq!(grid(&s).unwrap().step, want, "{step}"); } // A system by WKT or PROJ string too; "" goes back to the map's (or the UTM zone). s.run("layout.update", &json!({"element": frame, "grid": {"crs": "+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs"}})).unwrap(); assert!(grid(&s).unwrap().crs.unwrap().starts_with("+proj=utm")); s.run("layout.update", &json!({"element": frame, "grid": {"crs": ""}})).unwrap(); assert_eq!(grid(&s).unwrap().crs, None); let before = grid(&s); for bad in [ json!({"type": "utm"}), json!({"crs": "EPSG:4326"}), json!({"crs": "EPSG:1"}), json!({"crs": "not a system"}), json!({"crs": 25832}), json!({"crs": format!("EPSG:25832 {}", "x".repeat(rustgis_map::layout::MAX_GRID_CRS))}), ] { assert!(s.run("layout.update", &json!({"element": frame, "grid": bad})).is_err(), "{bad}"); let mut p = bad.clone(); p["kind"] = json!("grid"); assert!(s.run("layout.addElement", &p).is_err(), "{bad}"); } assert_eq!(grid(&s), before, "a refusal changes nothing"); // Back to a graticule: degrees again, the step starting afresh; kept with the project. s.run("layout.update", &json!({"element": frame, "grid": {"type": "measured", "crs": "EPSG:2056", "step": 500}})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!( match &reopened.project.layouts[0].element(frame).unwrap().kind { ElementKind::MapFrame { grid, .. } => grid.clone(), _ => None, }, grid(&s) ); s.run("layout.update", &json!({"element": frame, "grid": {"type": "graticule"}})).unwrap(); let g = grid(&s).unwrap(); assert_eq!((g.kind, g.step), (GridKind::Graticule, 0.0)); s.run("layout.update", &json!({"element": frame, "grid": {"step": 500}})).unwrap(); assert_eq!(grid(&s).unwrap().step, 90.0, "a graticule's step is degrees"); // It draws (a PNG of the page). let path = std::env::temp_dir().join(format!("rustgis-measured-{}.png", std::process::id())); s.run("layout.update", &json!({"element": frame, "grid": {"type": "measured", "step": 1000000}})).unwrap(); s.run("layout.export", &json!({"path": path, "dpi": 72})).unwrap(); assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 1000)); let _ = std::fs::remove_file(&path); } /// Grids, north arrows and scale bars take their cartographic options through commands (#327): /// checked before anything changes, kept when other options change, and saved with the layout. #[test] fn layout_surrounds_take_cartographic_options() { use rustgis_map::Rgba; use rustgis_map::layout::{ArrowStyle, CoordFormat, ElementKind, GridEdges, GridStyle, ScaleBarStyle, ScaleUnits}; let mut s = world(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let find = |s: &Session, code: &str| s.project.layouts[0].elements.iter().find(|e| e.kind.code() == code).unwrap().clone(); let frame = find(&s, "mapFrame").id; let grid = |s: &Session| match &s.project.layouts[0].element(frame).unwrap().kind { ElementKind::MapFrame { grid, .. } => grid.clone(), k => panic!("{k:?}"), }; // A grid with ticks along the top and right edges, in degrees and minutes. s.run( "layout.addElement", &json!({"kind": "grid", "step": 0.5, "style": "ticks", "tickLength": 3, "format": "dms", "edges": ["top", "right"], "labelOrientation": "vertical"}), ) .unwrap(); let g = grid(&s).unwrap(); assert_eq!((g.step, g.style, g.tick_length, g.format), (0.5, GridStyle::Ticks, 3.0, CoordFormat::Dms)); assert_eq!(g.edges, GridEdges { top: true, bottom: false, left: false, right: true }); // Adding it again changes only what is named; a bad value changes nothing. s.run("layout.addElement", &json!({"kind": "grid", "width": 1.5, "hemisphere": false})).unwrap(); let g2 = grid(&s).unwrap(); assert_eq!((g2.width, g2.hemisphere, g2.style, g2.step), (1.5, false, GridStyle::Ticks, 0.5)); for bad in [json!({"style": "dotted"}), json!({"format": "utm"}), json!({"edges": ["middle"]}), json!({"labelOrientation": "diagonal"})] { let mut p = bad.clone(); p["kind"] = json!("grid"); assert!(s.run("layout.addElement", &p).is_err(), "{bad}"); assert!(s.run("layout.update", &json!({"element": frame, "grid": bad})).is_err(), "{bad}"); } assert_eq!(grid(&s).unwrap(), g2); // The frame's properties edit, remove and restore its grid; sizes are clamped. s.run("layout.update", &json!({"element": frame, "grid": {"style": "linesAndTicks", "tickLength": 1e9, "edges": "bottom, left"}})) .unwrap(); let g3 = grid(&s).unwrap(); assert_eq!((g3.style, g3.tick_length, g3.edges), (GridStyle::LinesAndTicks, rustgis_map::layout::MAX_TICK_MM, GridEdges::default())); s.run("layout.update", &json!({"element": frame, "grid": null})).unwrap(); assert_eq!(grid(&s), Some(g3), "null is not set: the grid stays"); s.run("layout.update", &json!({"element": frame, "grid": false})).unwrap(); assert!(grid(&s).is_none()); s.run("layout.update", &json!({"element": frame, "grid": true})).unwrap(); assert_eq!(grid(&s), Some(rustgis_map::layout::Grid::default())); let arrow = find(&s, "northArrow").id; assert!(s.run("layout.update", &json!({"element": arrow, "grid": true})).is_err(), "only frames have grids"); // North arrows: a style and colours. s.run("layout.update", &json!({"element": arrow, "style": "compass", "fill": "#AA0000"})).unwrap(); assert!(matches!( find(&s, "northArrow").kind, ElementKind::NorthArrow { style: ArrowStyle::Compass, fill, outline, .. } if fill == Rgba::rgb(0xAA, 0, 0) && outline == Rgba::rgb(0x22, 0x22, 0x22) )); assert!(s.run("layout.update", &json!({"element": arrow, "style": "sundial"})).is_err()); assert!(s.run("layout.update", &json!({"element": arrow, "outline": "red"})).is_err()); assert!(matches!(find(&s, "northArrow").kind, ElementKind::NorthArrow { style: ArrowStyle::Compass, .. })); let star = s.run("layout.addElement", &json!({"kind": "northArrow", "style": "STAR"})).unwrap()["element"].as_u64().unwrap() as u32; assert!(matches!(s.project.layouts[0].element(star).unwrap().kind, ElementKind::NorthArrow { style: ArrowStyle::Star, .. })); // Scale bars: units, divisions and a style; divisions are clamped. let bar = find(&s, "scaleBar").id; s.run("layout.update", &json!({"element": bar, "units": "mi", "divisions": 99, "style": "stepped"})).unwrap(); assert!(matches!( find(&s, "scaleBar").kind, ElementKind::ScaleBar { units: ScaleUnits::Miles, divisions: 10, style: ScaleBarStyle::Stepped, .. } )); s.run("layout.update", &json!({"element": bar, "divisions": "2"})).unwrap(); assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { units: ScaleUnits::Miles, divisions: 2, .. })); // Its colours: the lines and labels, and the alternating bar's light divisions; one given // leaves the other as it was. assert!(matches!( find(&s, "scaleBar").kind, ElementKind::ScaleBar { color, fill, .. } if color == Rgba::rgb(0x22, 0x22, 0x22) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF) )); s.run("layout.update", &json!({"element": bar, "color": "#1F4E79"})).unwrap(); s.run("layout.update", &json!({"element": bar, "fill": "#00000080"})).unwrap(); assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { fill, .. } if fill == Rgba::new(0, 0, 0, 0x80))); s.run("layout.update", &json!({"element": bar, "fill": "#FDE68A"})).unwrap(); assert!(matches!( find(&s, "scaleBar").kind, ElementKind::ScaleBar { color, fill, divisions: 2, .. } if color == Rgba::rgb(0x1F, 0x4E, 0x79) && fill == Rgba::rgb(0xFD, 0xE6, 0x8A) )); let bad = [ json!({"units": "parsecs"}), json!({"divisions": "many"}), json!({"style": "zigzag"}), json!({"color": "red"}), json!({"fill": "#12"}), json!({"fill": 5}), json!({"color": [255, 0, 0]}), ]; for bad in bad { let mut p = bad.clone(); p["element"] = json!(bar); assert!(s.run("layout.update", &p).is_err(), "{bad}"); } // A text's colour is refused as anything but text, when updated as when added; null keeps it. let text = s.run("layout.addElement", &json!({"kind": "text", "text": "T", "color": "#1F4E79"})).unwrap(); let text = text["element"].as_u64().unwrap() as u32; let text_color = |s: &Session| match &s.project.layouts[0].element(text).unwrap().kind { ElementKind::Text { color, .. } => *color, _ => panic!("a text"), }; for bad in [json!(5), json!([255, 0, 0]), json!(true)] { assert!(s.run("layout.addElement", &json!({"kind": "text", "text": "U", "color": bad})).is_err(), "{bad}"); assert!(s.run("layout.update", &json!({"element": text, "color": bad})).is_err(), "{bad}"); } s.run("layout.update", &json!({"element": text, "color": null, "text": "V"})).unwrap(); assert_eq!(text_color(&s), Rgba::rgb(0x1F, 0x4E, 0x79)); s.run("layout.update", &json!({"element": text, "color": "#B91C1C"})).unwrap(); assert_eq!(text_color(&s), Rgba::rgb(0xB9, 0x1C, 0x1C)); let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line", "color": "#B91C1C"})).unwrap(); let added = added["element"].as_u64().unwrap() as u32; assert!(matches!( s.project.layouts[0].element(added).unwrap().kind, ElementKind::ScaleBar { units: ScaleUnits::Feet, divisions: 4, style: ScaleBarStyle::Line, color, fill, .. } if color == Rgba::rgb(0xB9, 0x1C, 0x1C) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF) )); // The page draws with them all, and they survive a save. let out = std::env::temp_dir().join(format!("rustgis-surrounds-{}.png", std::process::id())); s.run("layout.export", &json!({"path": out.to_string_lossy()})).unwrap(); let _ = std::fs::remove_file(&out); let text = serde_json::to_string(&s.project.layouts[0]).unwrap(); let back: rustgis_map::layout::Layout = serde_json::from_str(&text).unwrap(); assert_eq!(back, s.project.layouts[0]); } /// A layout element refuses a colour, fill or outline its kind doesn't draw, before anything /// changes: added and edited through commands, with the layouts unchanged after a refusal. #[test] fn layout_colours_are_refused_for_elements_without_them() { use rustgis_map::Rgba; use rustgis_map::layout::ElementKind; let mut s = world(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let layouts = |s: &Session| serde_json::to_string(&s.project.layouts).unwrap(); let find = |s: &Session, code: &str| s.project.layouts[0].elements.iter().find(|e| e.kind.code() == code).unwrap().id; let frame = find(&s, "mapFrame"); let red = "#B91C1C"; // Added: a kind without the colour is refused, nothing added; with it, it is applied. let refused = [ ("legend", "color", "only text, lines"), ("table", "color", "only text, lines"), ("colorbar", "color", "only text, lines"), ("northArrow", "color", "only text, lines"), ("legend", "fill", "take a fill"), ("table", "fill", "take a fill"), ("text", "fill", "take a fill"), ("line", "fill", "take a fill"), ("grid", "fill", "take a fill"), ("legend", "outline", "take an outline"), ("scaleBar", "outline", "take an outline"), ("text", "outline", "take an outline"), ("line", "outline", "take an outline"), ("extentIndicator", "outline", "take an outline"), ]; for (kind, key, said) in refused { let before = layouts(&s); let e = s.run("layout.addElement", &json!({"kind": kind, key: red})).unwrap_err(); assert!(e.contains(said) && layouts(&s) == before, "{kind} {key}: {e}"); // null is not given. let mut p = json!({"kind": kind}); p[key] = Value::Null; let after = s.run("layout.addElement", &p); assert!(after.as_ref().err().is_none_or(|e| !e.contains("take a")), "{kind} {key} null: {after:?}"); } let before = layouts(&s); let e = s.run("layout.addElement", &json!({"kind": "picture", "color": red})).unwrap_err(); assert!(e.contains("take a color") && layouts(&s) == before, "{e}"); for (kind, key) in [ ("text", "color"), ("dynamicText", "color"), ("line", "color"), ("arrow", "color"), ("rectangle", "color"), ("ellipse", "color"), ("circle", "color"), ("grid", "color"), ("scaleBar", "color"), ("scaleBar", "fill"), ("northArrow", "fill"), ("northArrow", "outline"), ("rectangle", "fill"), ("rectangle", "outline"), ("ellipse", "fill"), ("circle", "outline"), ] { s.run("layout.addElement", &json!({"kind": kind, key: red})).unwrap_or_else(|e| panic!("{kind} {key}: {e}")); } // An extent indicator's colour is its frame's: the template has one map frame, so add another. let inset = s.run("layout.addFrame", &json!({"x": 150, "y": 150, "w": 40, "h": 40})).unwrap()["element"].as_u64().unwrap(); s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": inset, "of": frame, "color": red})).unwrap(); let Some(ElementKind::MapFrame { extent_of, extent_color, .. }) = s.project.layouts[0].element(inset as u32).map(|e| &e.kind) else { panic!("the inset is a map frame") }; assert_eq!((*extent_of, extent_color.hex()), (Some(frame), red.to_string())); // A rectangle's outline is its "outline", else its "color". let r = s.run("layout.addElement", &json!({"kind": "rectangle", "outline": "#112233", "color": red})).unwrap()["element"].as_u64().unwrap(); let outline = |s: &Session, id: u64| match &s.project.layouts[0].element(id as u32).unwrap().kind { ElementKind::Rectangle { outline, .. } | ElementKind::Ellipse { outline, .. } => *outline, k => panic!("{k:?}"), }; assert_eq!(outline(&s, r), Rgba::rgb(0x11, 0x22, 0x33)); // Updated: the same, the element unchanged when refused. let legend = s.run("layout.addElement", &json!({"kind": "legend"})).unwrap()["element"].as_u64().unwrap(); let scale = find(&s, "scaleBar"); let table = s.run("layout.addElement", &json!({"kind": "table"})).unwrap()["element"].as_u64().unwrap(); for (el, key, said) in [ (legend, "color", "take a color"), (u64::from(frame), "color", "take a color"), (table, "color", "take a color"), (legend, "fill", "take a fill"), (table, "fill", "take a fill"), (legend, "outline", "take an outline"), (u64::from(frame), "outline", "take an outline"), ] { let before = layouts(&s); let e = s.run("layout.update", &json!({"element": el, key: red, "name": "Renamed"})).unwrap_err(); assert!(e.contains(said) && layouts(&s) == before, "{el} {key}: {e}"); s.run("layout.update", &json!({"element": el, key: null})).unwrap(); } let arrow = find(&s, "northArrow"); let e = s.run("layout.update", &json!({"element": arrow, "color": red})).unwrap_err(); assert!(e.contains("take a color"), "{e}"); let scale = u64::from(scale); for (el, key) in [ (u64::from(arrow), "fill"), (u64::from(arrow), "outline"), (r, "fill"), (r, "outline"), (r, "color"), (scale, "color"), (scale, "fill"), ] { s.run("layout.update", &json!({"element": el, key: red})).unwrap_or_else(|e| panic!("{el} {key}: {e}")); } let text = find(&s, "text"); s.run("layout.update", &json!({"element": text, "color": red})).unwrap(); let ElementKind::Text { color, .. } = &s.project.layouts[0].element(text).unwrap().kind else { panic!() }; assert_eq!(*color, Rgba::rgb(0xB9, 0x1C, 0x1C)); let e = s.run("layout.update", &json!({"element": text, "fill": red})).unwrap_err(); assert!(e.contains("take a fill"), "{e}"); } /// A colorbar shows a raster's colour scale on a layout (#326): added and edited through /// commands, checked against the frame's map, and saved with the layout. #[test] fn layout_colorbars_follow_a_raster() { use rustgis_map::layout::{ElementKind, Orientation, TickSide}; let mut s = world(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let kind = |s: &Session, id: u32| s.project.layouts[0].element(id).unwrap().kind.clone(); // No raster yet: it follows the top visible one once there is one. let id = s.run("layout.addElement", &json!({"kind": "colorbar"})).unwrap()["element"].as_u64().unwrap() as u32; assert!(matches!(kind(&s, id), ElementKind::Colorbar { layer: None, ticks: 5, reverse: false, .. }), "{:?}", kind(&s, id)); assert!(s.run("layout.addElement", &json!({"kind": "colorbar", "layer": "Countries"})).is_err(), "a feature layer has no colour scale"); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); let named = s .run("layout.addElement", &json!({"kind": "colorbar", "layer": dem.to_uppercase(), "orientation": "vertical", "ticks": 1e9, "tickSide": "left", "reverse": true})) .unwrap()["element"] .as_u64() .unwrap() as u32; // "" follows the top raster, as in layout.update. let blank = s.run("layout.addElement", &json!({"kind": "colorbar", "layer": " "})).unwrap()["element"].as_u64().unwrap() as u32; assert!(matches!(kind(&s, blank), ElementKind::Colorbar { layer: None, .. }), "{:?}", kind(&s, blank)); let e = s.project.layouts[0].element(named).unwrap().clone(); assert!(e.h > e.w, "a vertical bar is tall: {} × {}", e.w, e.h); match &e.kind { ElementKind::Colorbar { layer, title, orientation, ticks, tick_side, reverse, .. } => { assert_eq!(layer.as_deref(), Some(dem.as_str()), "the layer's own name"); assert_eq!(title, &dem, "titled after the layer"); assert_eq!( (*orientation, *ticks, *tick_side, *reverse), (Orientation::Vertical, rustgis_map::layout::MAX_COLORBAR_TICKS, TickSide::Start, true) ); } k => panic!("{k:?}"), } // Edited: a bad value changes nothing. for bad in [json!({"orientation": "diagonal"}), json!({"tickSide": "inside"}), json!({"layer": "Countries"}), json!({"layer": "nope"})] { let mut p = bad.clone(); p["element"] = json!(named); assert!(s.run("layout.update", &p).is_err(), "{bad}"); } assert_eq!(s.project.layouts[0].element(named).unwrap(), &e); // Turned horizontal, it swaps its length and thickness; "" follows the top raster again. s.run( "layout.update", &json!({"element": named, "orientation": "horizontal", "title": "Elevation (m)", "ticks": 3, "tickSide": "below", "layer": ""}), ) .unwrap(); let turned = s.project.layouts[0].element(named).unwrap().clone(); assert_eq!((turned.w, turned.h), (e.h, e.w)); assert!( matches!( &turned.kind, ElementKind::Colorbar { layer: None, orientation: Orientation::Horizontal, ticks: 3, tick_side: TickSide::End, reverse: true, title, .. } if title == "Elevation (m)" ), "{:?}", turned.kind ); // It draws the raster's ramp: the export differs from the page without it. let png = |s: &mut Session| crate::cmd::layout::layout_bytes(s, 0, false, 72.0, false).unwrap(); let with = png(&mut s); s.run("layout.update", &json!({"element": named, "visible": false})).unwrap(); s.run("layout.update", &json!({"element": id, "visible": false})).unwrap(); assert_ne!(with, png(&mut s)); // Saved and read back, and layouts from before colorbars had their defaults read as before. let text = serde_json::to_string(&s.project.layouts[0]).unwrap(); let back: rustgis_map::layout::Layout = serde_json::from_str(&text).unwrap(); assert_eq!(back, s.project.layouts[0]); let old: ElementKind = serde_json::from_str(r#"{"Colorbar":{"frame":1}}"#).unwrap(); assert!(matches!( old, ElementKind::Colorbar { layer: None, ticks: 5, orientation: Orientation::Horizontal, tick_side: TickSide::End, reverse: false, .. } )); // Undone with the rest of the layout's edits. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.layouts[0].element(id).map(|e| e.visible), Some(true), "undo brings it back"); } /// `layout.publication` (#682): every profile builds a layout that fits its page, the map frame /// at a round scale that still fits the extent, the footer boxed in two columns below the map /// with long lines wrapped, the tokens filled from the map; a figure leaves its title to the /// caption. #[test] fn publication_layouts_fit_their_pages() { use rustgis_map::layout::ElementKind; let dir = std::env::temp_dir().join(format!("rustgis-publication-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let piaui = json!([-46.8, -11.6, -38.5, -2.2]); let long = "NDVI: MOD13Q1 v6.1 (NASA LP DAAC), 16-day composites at 250 m, read through the Brazil Data Cube (INPE) and averaged to 1 km"; let profiles = [ "a4-portrait", "a4-landscape", "a3-portrait", "a3-landscape", "a2-portrait", "a2-landscape", "figure-1col", "figure-1.5col", "figure-2col", "thesis-full", "thesis-half", ]; for profile in profiles { let r = s .run( "layout.publication", &json!({"profile": profile, "extent": piaui, "name": profile, "title": "PIAUÍ", "subtitle": "Greening and browning of vegetation: NDVI trend, 2001–2025", "sources": [long, "Boundaries: IBGE 2022"], "note": "Only cells with a significant trend (p < 0.05) are coloured."}), ) .unwrap_or_else(|e| panic!("{profile}: {e}")); let l = s.project.layouts.last().unwrap(); assert_eq!(r["name"], profile); let sheet = profile.starts_with('a'); for e in &l.elements { assert!( e.x >= -1e-9 && e.y >= -1e-9 && e.x + e.w <= l.width + 1e-6 && e.y + e.h <= l.height + 1e-6, "{profile}: {} at ({}, {}, {}, {}) is off the {} × {} page", e.name, e.x, e.y, e.w, e.h, l.width, l.height ); } let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); let ElementKind::MapFrame { scale, grid: Some(grid), .. } = &frame.kind else { panic!("{profile}: no grid") }; // The legend stops short of the scale bar's box, whichever corners they take, however // narrow the frame. let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap(); let (a, b) = (named("Legend Box"), named("Scale Box")); let apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y; assert!(apart, "{profile}: the legend reaches the scale bar"); // A round scale, two significant figures, at which the extent still fits. let unit = 10f64.powf(scale.log10().floor() - 1.0); assert!(((scale / unit) - (scale / unit).round()).abs() < 1e-6, "{profile}: 1:{scale}"); let extent = rustgis_geom::Extent::new(-46.8, -11.6, -38.5, -2.2); // Fitted within the graticule's label strips (9 mm beside the frame's sides, 6 mm along its // top and bottom), so the labels cover none of the extent. let (_, fits) = rustgis_map::layout::fit_scale(s.map(), &extent, frame.w - 18.0, frame.h - 12.0).unwrap(); assert!(fits <= *scale && *scale < fits * 1.1, "{profile}: 1:{scale} for 1:{fits}"); assert_eq!(grid.step, 2.0, "{profile}: Piauí's 9.4° take a 2° graticule"); let text = |name: &str| { l.elements.iter().find(|e| e.name == name).map(|e| match &e.kind { ElementKind::Text { text, .. } => text.clone(), _ => panic!("{name} isn't text"), }) }; if sheet { assert_eq!(text("Title").as_deref(), Some("PIAUÍ"), "{profile}"); // The footer under the map, the long source wrapped within its box. let foot = l.elements.iter().find(|e| e.name == "Sources Box").unwrap(); assert!(foot.y >= frame.y + frame.h, "{profile}: the footer is under the map"); let sources = l.elements.iter().find(|e| e.name == "Sources").unwrap(); assert!(sources.x + sources.w <= foot.x + foot.w + 1e-6); let lines = text("Sources").unwrap(); assert!(lines.lines().count() >= 3, "{profile}: {lines}"); for line in lines.lines() { assert!( crate::render::report::width_mm(line, 7.0 * l.width.min(l.height) as f32 / 210.0, false) <= sources.w + 1e-6, "{profile}: {line}" ); } // The tokens filled from the map: Web Mercator over WGS 84, the 2° graticule. let reference = text("Reference").unwrap(); assert!(reference.contains("Web Mercator") && reference.contains("WGS 84") && reference.contains("2°"), "{reference}"); assert!(text("Author").unwrap().contains("{date}"), "the date stays a token the layout fills"); // Nothing said but, over the sample world's land, the surrounds sharing its one corner // of sea, or the map moved in its frame for the legend (placement has its own tests). let said: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).collect(); assert!( said.len() <= 2 && said.iter().all(|w| w.contains("on what the map shows") || w.contains("in its frame")), "{profile}: {r}" ); } else { assert!(text("Title").is_none() && text("Sources").is_none(), "{profile}: a figure's texts are its caption's"); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("caption")), "{r}"); assert!(text("Reference").unwrap().contains("Datum: WGS 84"), "{profile}"); assert_eq!(l.width, r["page"][0].as_f64().unwrap()); } let png = dir.join(format!("{profile}.png")); s.run("layout.export", &json!({"path": png.display().to_string(), "dpi": 72})).unwrap(); assert!(std::fs::metadata(&png).unwrap().len() > 1000, "{profile}"); } assert_eq!(s.project.layouts.len(), profiles.len(), "a layout each, the earlier ones untouched"); // A long title wraps within the frame, and pushes the map down; the result names every // element for layout.update. let r = s.run("layout.publication", &json!({"extent": piaui, "title": long})).unwrap(); let l = s.project.layouts.last().unwrap(); let title = l.elements.iter().find(|e| e.name == "Title").unwrap(); let ElementKind::Text { text, size, .. } = &title.kind else { panic!() }; assert!(text.lines().count() >= 2, "{text}"); for line in text.lines() { assert!(crate::render::report::width_mm(line, *size as f32, true) <= title.w, "{line}: measured bold, as drawn"); } let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); assert!(frame.y >= title.y + title.h); let listed = r["elements"].as_array().unwrap(); assert_eq!(listed.len(), l.elements.len()); assert!(listed.iter().any(|e| e["name"] == "Map Frame" && e["element"] == frame.id && e["kind"] == "mapFrame"), "{r}"); s.project.layouts.pop(); // Undone as one command: the layout goes, and the map is as it was (its layers, view and // projection), the legend's sync of streamed layers included. let (layouts, map) = (s.project.layouts.len(), s.map().clone()); s.run("layout.publication", &json!({"extent": piaui})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.layouts.len(), layouts); let ids = |m: &rustgis_map::Map| m.layers.iter().map(|l| (l.id, l.name.clone(), l.visible)).collect::>(); assert_eq!(ids(s.map()), ids(&map)); assert_eq!((s.map().projection.code(), s.map().camera.center), (map.projection.code(), map.camera.center)); // A figure says it left out an author and headings given to it, as it does a title. let r = s.run("layout.publication", &json!({"profile": "figure-1col", "extent": piaui, "author": "Me", "headings": ["A", "B"]})).unwrap(); let said = r["warnings"].to_string(); assert!(said.contains("author goes") && said.contains("headings goes"), "{said}"); s.project.layouts.pop(); let r = s.run("layout.publication", &json!({"profile": "figure-1col", "extent": piaui, "author": ""})).unwrap(); assert!(!r["warnings"].to_string().contains("author"), "an empty author asks for none: {r}"); s.project.layouts.pop(); // An empty author leaves the authorship out. for none in [json!(""), json!([]), json!([" "])] { s.run("layout.publication", &json!({"extent": piaui, "author": none})).unwrap(); assert!(s.project.layouts.last().unwrap().elements.iter().all(|e| e.name != "Author"), "{none}"); s.project.layouts.pop(); } // With no sources, the reference alone takes the footer's width; no empty box beside it. s.run("layout.publication", &json!({"extent": piaui})).unwrap(); let l = s.project.layouts.last().unwrap(); let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); assert!(l.elements.iter().all(|e| !e.name.starts_with("Sources"))); let reference = l.elements.iter().find(|e| e.name == "Reference Box").unwrap(); assert!((reference.x - frame.x).abs() < 1e-9 && (reference.w - frame.w).abs() < 1e-9, "{} of {}", reference.w, frame.w); s.project.layouts.pop(); // Headings rename what the columns read, filled and wrapped; their elements keep their names. let r = s .run( "layout.publication", &json!({"extent": piaui, "sources": "IBGE", "headings": ["Fontes dos dados, {date}", "Referência cartográfica na projeção {projection}, a mais longa que se possa imaginar"]}), ) .unwrap(); let l = s.project.layouts.last().unwrap(); let heading = l.elements.iter().find(|e| e.name == "Reference Heading").unwrap(); let ElementKind::Text { text, .. } = &heading.kind else { panic!() }; // Where the wrap breaks depends on the font found, so the words are read across the lines. assert!(text.replace('\n', " ").contains("WEB MERCATOR") && !text.contains('{') && text.lines().count() >= 2, "{text}"); // A live token in a heading keeps its case, so the layout still fills it when it draws. let sources = l.elements.iter().find(|e| e.name == "Sources Heading").unwrap(); let ElementKind::Text { text, .. } = &sources.kind else { panic!() }; assert_eq!(text, "FONTES DOS DADOS, {date}"); assert!(l.elements.iter().any(|e| e.name == "Sources Heading") && l.elements.iter().any(|e| e.name == "Reference"), "{r}"); let body = l.elements.iter().find(|e| e.name == "Reference").unwrap(); assert!(body.y >= heading.y + heading.h - 1e-9, "the reference starts under its wrapped heading"); s.project.layouts.pop(); // A journal figure is as tall as the map's shape asks: Piauí in Web Mercator is taller than wide. let one = s.project.layouts.iter().find(|l| l.name == "figure-1col").unwrap(); assert!(one.width == 90.0 && one.height > 90.0, "{} × {}", one.width, one.height); // Graticule steps and round scales. use crate::cmd::layout::{capitals, datum_of, grid_step, round_scale, step_text}; assert_eq!(datum_of("+proj=aea +lat_1=29.5 +datum=NAD83 +units=m").as_deref(), Some("NAD83")); assert_eq!(datum_of("+proj=robin +ellps=WGS84").as_deref(), Some("WGS 84")); assert_eq!(datum_of("+proj=eqearth +R=6371008.8").as_deref(), Some("a sphere")); assert_eq!(datum_of("+proj=moll"), None); // A UTM zone's code is named by its zone and datum, not its ellipsoid. s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); s.run("layout.publication", &json!({"extent": [-46, -11, -40, -2.7]})).unwrap(); let l = s.project.layouts.last().unwrap(); let ElementKind::Text { text, .. } = &l.elements.iter().find(|e| e.name == "Reference").unwrap().kind else { panic!() }; assert!(text.contains("Projection: UTM zone 23S") && text.contains("Datum: SIRGAS 2000"), "{text}"); s.project.layouts.pop(); // A preset on NAD83 says so, though its name holds no datum. s.run("map.projection", &json!({"type": "usaAlbers"})).unwrap(); s.run("layout.publication", &json!({"extent": [-100, 35, -90, 45]})).unwrap(); let l = s.project.layouts.last().unwrap(); let ElementKind::Text { text, .. } = &l.elements.iter().find(|e| e.name == "Reference").unwrap().kind else { panic!() }; assert!(text.contains("Datum: NAD83"), "{text}"); s.project.layouts.pop(); assert_eq!(capitals("Fontes, {date} e {pageName}"), "FONTES, {date} E {pageName}"); assert_eq!(capitals("Dados {2024 e {só} }"), "DADOS {2024 E {SÓ} }", "a lone or odd brace is text"); assert_eq!(grid_step(&rustgis_geom::Extent::new(10.0, 45.0, 11.0, 46.0)), 0.25); assert_eq!(grid_step(&rustgis_geom::Extent::new(-180.0, -90.0, 180.0, 90.0)), 30.0); assert_eq!((step_text(2.0), step_text(0.25), step_text(0.02)), ("2°".to_string(), "15′".to_string(), "1′12″".to_string())); assert_eq!(step_text(0.01), "36″"); assert_eq!((round_scale(1_234_567.0), round_scale(4_500_000.0), round_scale(999.0)), (1_300_000.0, 4_500_000.0, 1_000.0)); // Refused: an unknown profile, a malformed extent, a footer that leaves the map no room. for (p, said) in [ (json!({"profile": "a5-portrait"}), "unknown profile"), (json!({"profile": 5}), "profile is"), (json!({"extent": [10, 0, 5, 1]}), "extent is"), (json!({"extent": [10, 0, 11], "profile": "a4-portrait"}), "extent is"), (json!({"sources": vec![long; 40], "profile": "a4-landscape", "extent": piaui}), "no room"), (json!({"reference": vec![long; 50], "profile": "thesis-half", "extent": piaui}), "no room"), ] { let e = s.run("layout.publication", &p).unwrap_err(); assert!(e.contains(said), "{p}: {e}"); } let _ = std::fs::remove_dir_all(&dir); } /// Each sheet style the New Layout menu offers is one `layout.publication` takes, by its name. #[test] fn every_publication_style_in_the_menu_is_taken() { let mut s = world(); for (style, _) in crate::cmd::layout::PUBLICATION_STYLES { let r = s.run("layout.publication", &json!({"extent": [-46.8, -11.6, -38.5, -2.2], "profile": "a4-portrait", "style": style})).unwrap(); assert_eq!(r["style"], *style); } } /// A publication sheet's style (#682): `classic` (the default) as before; `banner` the title and /// subtitle in a band of the accent colour across the top; `panel` the map at the left and a panel /// at the right holding the title, the locator, the legend, the sources and the reference; /// `editorial` the title large in serif and a column at the right with the note, the locator and /// the legend; `modular` cards with rounded corners; `poster` the page in the accent colour. /// /// A figure leaves a style to its caption; an unknown style or a bad accent is refused. #[test] fn publication_layouts_take_a_style() { use rustgis_map::layout::{ElementKind, FontFamily}; let dir = std::env::temp_dir().join(format!("rustgis-styles-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let piaui = json!([-46.8, -11.6, -38.5, -2.2]); // On a portrait sheet (the arrangement is what is checked; the orientation chosen for an area // has its own check below). let texts = json!({"extent": piaui, "profile": "a4-portrait", "title": "PIAUÍ", "subtitle": "Greening and browning of the vegetation, 2001–2025", "sources": ["NDVI: MOD13Q1 v6.1", "Boundaries: IBGE"], "note": "Only the cells with a significant trend are coloured.", "headings": ["Sources", "Reference", "About this map"]}); let make = |s: &mut Session, style: &str, extra: Value| { let mut p = texts.clone(); p["style"] = json!(style); for (k, v) in extra.as_object().unwrap() { p[k] = v.clone(); } let r = s.run("layout.publication", &p).unwrap(); s.run("layout.export", &json!({"path": dir.join(format!("{style}.png")).display().to_string(), "dpi": 48})).unwrap(); r }; let inside = |a: &rustgis_map::layout::Element, b: &rustgis_map::layout::Element| { a.x >= b.x - 1e-6 && a.y >= b.y - 1e-6 && a.x + a.w <= b.x + b.w + 1e-6 && a.y + a.h <= b.y + b.h + 1e-6 }; // Classic, the default: no band, no panel. let r = s.run("layout.publication", &texts).unwrap(); assert_eq!(r["style"], "classic"); let l = s.project.layouts.last().unwrap(); assert!(l.elements.iter().all(|e| !matches!(e.name.as_str(), "Banner" | "Panel Box" | "Column Rule"))); // Banner: a band of the accent colour at the top, the title on it in white, the map under it. let r = make(&mut s, "banner", json!({"accent": "#123456"})); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let (band, title, frame) = (named("Banner"), named("Title"), named("Map Frame")); let ElementKind::Rectangle { fill, .. } = &band.kind else { panic!() }; assert_eq!(*fill, rustgis_map::Rgba::rgb(0x12, 0x34, 0x56)); let ElementKind::Text { color, .. } = &title.kind else { panic!() }; assert_eq!(*color, rustgis_map::Rgba::rgb(0xFF, 0xFF, 0xFF)); assert!(inside(title, band) && inside(named("Subtitle"), band) && frame.y >= band.y + band.h, "{r}"); // Panel: the map at the left, the panel at the right holding the texts, the locator and the // legend; no boxed footer. // An area as tall as the map's narrow column (its frame filled, not cut): everything in the // panel. let tall = json!([-46.0, -34.0, -40.0, 4.0]); let r = make(&mut s, "panel", json!({"extent": tall})); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let (panel, frame) = (named("Panel Box"), named("Map Frame")); assert!(frame.x + frame.w <= panel.x, "the map left of the panel: {r}"); for n in ["Title", "Subtitle", "Locator", "Legend Box", "Sources Heading", "Sources", "Reference"] { assert!(inside(named(n), panel), "{n} in the panel: {r}"); } assert!(l.elements.iter().all(|e| e.name != "Sources Box")); // The legend under the locator, apart from it. assert!(named("Legend Box").y >= named("Locator").y + named("Locator").h, "{r}"); let full = frame.h; // A less tall area (Piauí) on that narrow column: the frame cut to its shape (a seventh more of its // surroundings), the sources and reference boxed under the map, and all of it in the middle of // the page; the neatline still round the whole sheet. let r = make(&mut s, "panel", json!({})); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let (panel, frame, sources) = (named("Panel Box"), named("Map Frame"), named("Sources Box")); assert!(frame.h < 0.9 * full && (panel.h - frame.h).abs() < 1e-6, "{} of {full}: {r}", frame.h); assert!(sources.y >= frame.y + frame.h && inside(named("Legend Box"), panel), "{r}"); let (top, bottom) = (frame.y, l.height - named("Author").y - named("Author").h); assert!((top - bottom).abs() < 1.0, "the content in the middle: {top} above, {bottom} below"); let neat = named("Frame"); assert!((neat.y + neat.h - (l.height - neat.y)).abs() < 1e-6, "the neatline round the sheet"); // In landscape too, every style on any sheet: the panel at the right of a wide map. for style in ["banner", "panel", "editorial"] { let r = make(&mut s, style, json!({"profile": "a4-landscape"})); let l = s.project.layouts.last().unwrap(); let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); assert!(frame.w > frame.h, "{style}: a wide map on a landscape sheet: {r}"); if style != "banner" { assert!( l.elements.iter().filter(|e| e.name == "Locator" || e.name == "Legend Box").all(|e| e.x >= frame.x + frame.w), "{style}: {r}" ); // The locator no larger than on a portrait sheet, though the column is wider. let locator = l.elements.iter().find(|e| e.name == "Locator").unwrap(); assert!(locator.w <= 52.0 + 1e-6, "{style}: {}", locator.w); } } // No locator: the legend takes its place at the top of the panel's free room. let r = make(&mut s, "panel", json!({"locator": "none"})); let l = s.project.layouts.last().unwrap(); assert!(l.elements.iter().all(|e| e.name != "Locator") && l.elements.iter().any(|e| e.name == "Legend Box"), "{r}"); // Editorial: the title in serif in the accent colour, a column at the right with its heading, // the note, the locator and the legend. let r = make(&mut s, "editorial", json!({})); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let ElementKind::Text { font, color, .. } = &named("Title").kind else { panic!() }; assert_eq!((*font, *color), (FontFamily::Serif, rustgis_map::Rgba::rgb(0x1F, 0x2D, 0x4D))); // Drawn in the sans where no serif is installed, said. let said = r["warnings"].to_string().contains("the editorial title and subtitle: no serif font"); assert_eq!(said, !crate::render::fonts::serif_available(), "{r}"); let frame = named("Map Frame"); for n in ["Column Heading", "Note", "Locator", "Legend Box"] { assert!(named(n).x >= frame.x + frame.w, "{n} right of the map: {r}"); } // Modular: the map in a card drawn under it, the locator and the legend each in a card at the // right, the footer's boxes with rounded corners and no neatline. let radius = |e: &rustgis_map::layout::Element| match &e.kind { ElementKind::Rectangle { radius, .. } => *radius, _ => 0.0, }; let r = make(&mut s, "modular", json!({})); let l = s.project.layouts.last().unwrap(); let at = |n: &str| l.elements.iter().position(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let named = |n: &str| &l.elements[at(n)]; assert!(at("Map Card") < at("Map Frame") && inside(named("Map Frame"), named("Map Card")), "{r}"); let frame = named("Map Frame"); for n in ["Locator Card", "Locator", "Legend Box"] { assert!(named(n).x >= frame.x + frame.w, "{n} right of the map: {r}"); } assert!(inside(named("Locator"), named("Locator Card")) && named("Legend Box").y >= named("Locator Card").y + named("Locator Card").h); assert!((named("Legend Box").w - named("Locator Card").w).abs() < 1e-6, "the two cards as wide: {r}"); assert!(radius(named("Map Card")) > 0.0 && radius(named("Legend Box")) > 0.0 && radius(named("Sources Box")) > 0.0); assert!(l.elements.iter().all(|e| e.name != "Frame")); // Poster: the page in the accent colour under everything, the title in white, the map in a // light card, the footer in rounded blocks. let r = make(&mut s, "poster", json!({})); let l = s.project.layouts.last().unwrap(); let at = |n: &str| l.elements.iter().position(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); let named = |n: &str| &l.elements[at(n)]; let back = named("Background"); assert_eq!((at("Background"), back.w, back.h), (0, l.width, l.height)); let ElementKind::Rectangle { fill, .. } = &back.kind else { panic!() }; assert_eq!(*fill, rustgis_map::Rgba::rgb(0x3A, 0x26, 0x18)); let ElementKind::Text { color, .. } = &named("Title").kind else { panic!() }; assert_eq!(*color, rustgis_map::Rgba::rgb(0xFF, 0xFF, 0xFF)); assert!(at("Map Card") < at("Map Frame") && inside(named("Map Frame"), named("Map Card")) && radius(named("Sources Box")) > 0.0); assert!(l.elements.iter().all(|e| e.name != "Banner" && e.name != "Frame")); // A rectangle's rounded corners by hand: added, changed, kept when its colour changes, saved only // when rounded. let id = s.run("layout.addElement", &json!({"kind": "rectangle", "x": 10, "y": 10, "w": 40, "h": 20, "radius": 4})).unwrap()["element"] .clone(); let rect = |s: &Session| s.project.layouts.last().unwrap().elements.iter().find(|e| json!(e.id) == id).unwrap().kind.clone(); assert!(matches!(rect(&s), ElementKind::Rectangle { radius, .. } if radius == 4.0)); s.run("layout.update", &json!({"element": id, "fill": "#FFFFFF"})).unwrap(); assert!(matches!(rect(&s), ElementKind::Rectangle { radius, .. } if radius == 4.0)); s.run("layout.update", &json!({"element": id, "radius": 0})).unwrap(); assert!(!serde_json::to_string(&rect(&s)).unwrap().contains("radius"), "square corners aren't saved"); assert!(s.run("layout.update", &json!({"element": id, "radius": -1})).is_err()); assert!(s.run("layout.addElement", &json!({"kind": "rectangle", "radius": "x"})).is_err()); // Anything but a rectangle refuses rounded corners, rather than taking them as applied. let n = s.project.layouts.last().unwrap().elements.len(); let e = s.run("layout.addElement", &json!({"kind": "ellipse", "radius": 4})).unwrap_err(); assert!(e.contains("only a rectangle rounds its corners") && s.project.layouts.last().unwrap().elements.len() == n, "{e}"); let oval = s.run("layout.addElement", &json!({"kind": "circle"})).unwrap()["element"].clone(); let e = s.run("layout.update", &json!({"element": oval, "radius": 4, "fill": "#FF0000"})).unwrap_err(); assert!(e.contains("only a rectangle rounds its corners"), "{e}"); let fill = |s: &Session| { s.project.layouts.last().unwrap().elements.iter().find(|e| json!(e.id) == oval).and_then(|e| match e.kind { ElementKind::Ellipse { fill, .. } => Some(fill), _ => None, }) }; assert!(fill(&s).is_some_and(|f| f != rustgis_map::Rgba::rgb(0xFF, 0, 0)), "an ellipse, unchanged"); // A sheet given its size alone (or none) is turned to the area's shape: portrait for a tall // one, landscape for a wide one, the size kept; one given its orientation keeps it. let wide = json!([-75.0, -14.0, -35.0, -2.0]); for (p, want) in [ (json!({"extent": piaui}), "a4-portrait"), (json!({"extent": wide}), "a4-landscape"), (json!({"extent": wide, "profile": "a3"}), "a3-landscape"), (json!({"extent": wide, "profile": "a4-portrait"}), "a4-portrait"), (json!({"extent": piaui, "profile": "a4-landscape"}), "a4-landscape"), ] { let r = s.run("layout.publication", &p).unwrap(); assert_eq!(r["profile"], want, "{p}"); let l = s.project.layouts.last().unwrap(); assert_eq!(l.width > l.height, want.ends_with("landscape"), "{p}"); } // A figure leaves the style to its caption; refused: an unknown style, a bad accent, either not // given as text, and an unknown profile (its message naming the sizes turned to the area). let r = s.run("layout.publication", &json!({"extent": piaui, "profile": "figure-1col", "style": "banner"})).unwrap(); assert!(r["warnings"].to_string().contains("style \\\"banner\\\""), "{r}"); let before = s.project.layouts.len(); for bad in [ json!({"extent": piaui, "style": "magazine"}), json!({"extent": piaui, "style": "banner", "accent": "blue"}), json!({"extent": piaui, "style": 3}), json!({"extent": piaui, "accent": 7}), json!({"extent": piaui, "style": "poster", "accent": "#1F2D4D80"}), ] { assert!(s.run("layout.publication", &bad).is_err(), "{bad}"); } let e = s.run("layout.publication", &json!({"extent": piaui, "profile": "a5"})).unwrap_err(); assert!(e.contains("a4, a3 or a2") && e.contains("a4-portrait"), "{e}"); assert_eq!(s.project.layouts.len(), before); let _ = std::fs::remove_dir_all(&dir); } /// A publication layout's locator (#682, part 2): on a sheet, by default, a globe (a new map of /// the vector layers on the globe, its diameter nearly the frame's) in a round frame at the map's /// top left, the area outlined by the map frame's extent indicator; `"inset"` the surrounding /// region on the map itself; `"none"`, or a figure by default, none. The legend stops short of it, /// and undo takes the globe's map away with the layout. #[test] fn publication_layouts_locate_their_area() { use rustgis_map::layout::{ElementKind, FrameShape}; let dir = std::env::temp_dir().join(format!("rustgis-locator-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let piaui = json!([-46.8, -11.6, -38.5, -2.2]); let maps = s.project.maps.len(); let main = s.map().name.clone(); let r = s.run("layout.publication", &json!({"extent": piaui, "title": "PIAUÍ"})).unwrap(); let globe = r["locator"].as_str().unwrap().to_string(); assert_eq!((s.project.maps.len(), globe.as_str()), (maps + 1, format!("{main} Locator").as_str())); let m = s.project.maps.iter().find(|m| m.name == globe).unwrap(); assert!(m.projection.is_globe() && m.layers.iter().all(|l| !l.is_raster())); assert_eq!(s.map().name, main, "the map in view stays the active one"); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}")); let (frame, locator) = (named("Map Frame"), named("Locator")); let ElementKind::MapFrame { map, shape, extent_of, scale, .. } = &locator.kind else { panic!() }; assert_eq!((map.as_str(), shape, *extent_of), (globe.as_str(), &FrameShape::Circle, Some(frame.id))); // Inside the map frame (in a corner of its own: see publication_surrounds_keep_off_the_data), // clear of the legend. assert!( locator.x > frame.x && locator.y > frame.y && locator.x + locator.w < frame.x + frame.w && locator.y + locator.h < frame.y + frame.h ); let legend = named("Legend Box"); let apart = legend.x + legend.w <= locator.x || locator.x + locator.w <= legend.x || legend.y + legend.h <= locator.y || locator.y + locator.h <= legend.y; assert!(apart, "the legend stops short of the locator"); // The globe nearly fills its frame: drawn, its ink spans most of the frame's width. assert!(*scale > 1e7, "1:{scale}"); let png = dir.join("globe.png"); s.run("layout.export", &json!({"path": png.display().to_string(), "dpi": 96})).unwrap(); // The surrounding region on the map itself: no new map, a coarser scale. let r = s.run("layout.publication", &json!({"extent": piaui, "locator": "inset"})).unwrap(); assert_eq!((s.project.maps.len(), r["locator"].as_str()), (maps + 1, Some(main.as_str()))); let l = s.project.layouts.last().unwrap(); let main_scale = r["scale"].as_f64().unwrap(); let inset = l.elements.iter().find(|e| e.name == "Locator").unwrap(); let ElementKind::MapFrame { map, shape, scale, extent_of, .. } = &inset.kind else { panic!() }; assert!(map == &main && shape == &FrameShape::Rectangle && *scale > main_scale * 3.0 && extent_of.is_some(), "1:{scale}"); s.run("layout.export", &json!({"path": dir.join("inset.png").display().to_string(), "dpi": 96})).unwrap(); // None asked, or a figure by default: no locator, and the legend stays, the narrowest // figure (90 mm) included: only a locator takes its room. for p in [json!({"extent": piaui, "locator": "none"}), json!({"extent": piaui, "profile": "figure-1col"})] { let r = s.run("layout.publication", &p).unwrap(); let l = s.project.layouts.last().unwrap(); assert!(r["locator"].is_null() && l.elements.iter().all(|e| e.name != "Locator"), "{p}"); assert!(l.elements.iter().any(|e| e.name == "Legend Box"), "{p}: {r}"); } let e = s.run("layout.publication", &json!({"extent": piaui, "locator": "moon"})).unwrap_err(); assert!(e.contains("locator is"), "{e}"); // Undone, a globe locator's map goes with its layout. let before = s.project.maps.len(); s.run("layout.publication", &json!({"extent": piaui})).unwrap(); assert_eq!(s.project.maps.len(), before + 1); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.maps.len(), before); // A sheet made again after its layout was deleted replaces the locator no layout shows any // more, rather than leaving it beside a new one; one still shown is kept. let r = s.run("layout.publication", &json!({"extent": piaui, "name": "Again"})).unwrap(); let first = r["locator"].as_str().unwrap().to_string(); s.run("layout.remove", &json!({"name": "Again"})).unwrap(); // Made anew, not kept: a polygon layer hidden meanwhile is gone from the reused map. let (gone, gone_name) = s.map().layers.iter().find(|l| l.geometry_type() == rustgis_data::GeometryType::Polygon).map(|l| (l.id, l.name.clone())).unwrap(); let globe_has = |s: &Session, name: &str| s.project.maps.iter().find(|m| m.name == first).unwrap().layers.iter().any(|l| l.name == name); assert!(globe_has(&s, &gone_name)); s.map_mut().layer_mut(gone).unwrap().visible = false; let r = s.run("layout.publication", &json!({"extent": piaui, "name": "Again"})).unwrap(); assert_eq!((s.project.maps.len(), r["locator"].as_str()), (before + 1, Some(first.as_str()))); assert!(r["warnings"].to_string().contains("which no layout showed, is replaced"), "{r}"); assert!(!globe_has(&s, &gone_name), "the reused map is rebuilt"); s.map_mut().layer_mut(gone).unwrap().visible = true; let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); assert!(s.project.maps.len() == before + 2 && r["locator"].as_str() != Some(first.as_str()), "{r}"); // A globe map made by hand under such a name (its layers labelled) is never taken for one. let mut hand = rustgis_map::Map::new(format!("{main} Locator 9")); hand.set_projection(rustgis_map::Projection::Globe); let mut labelled = s.map().layers.iter().find(|l| l.geometry_type() == rustgis_data::GeometryType::Polygon).unwrap().clone(); labelled.labels.enabled = true; hand.layers.push(labelled); s.project.maps.push(hand); let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); assert_ne!(r["locator"].as_str(), Some(format!("{main} Locator 9").as_str()), "{r}"); let kept = s.project.maps.iter().find(|m| m.name == format!("{main} Locator 9")).unwrap(); assert!(kept.layers.iter().all(|l| l.labels.enabled)); // The globe is a new map of what it draws: the polygon layers that show (a hidden one left // out) on the map's basemap, without its web tiles, swipe or groups. let polygon = |l: &rustgis_map::Layer| l.geometry_type() == rustgis_data::GeometryType::Polygon; let hidden = s.map().layers.iter().find(|l| polygon(l)).map(|l| (l.id, l.name.clone())).unwrap(); s.map_mut().layer_mut(hidden.0).unwrap().visible = false; s.map_mut().tiles.push(rustgis_map::TileLayer::new( "Streets", rustgis_map::TileKind::Raster, "https://tiles.example.com/{z}/{x}/{y}.png", )); s.map_mut().swipe = Some((hidden.0, 0.5)); let shown: Vec = s.map().layers.iter().filter(|l| polygon(l) && s.map().shows(l)).map(|l| l.name.clone()).collect(); let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); let m = s.project.maps.iter().find(|m| Some(m.name.as_str()) == r["locator"].as_str()).unwrap(); assert_eq!(m.layers.iter().map(|l| l.name.clone()).collect::>(), shown, "not {}", hidden.1); assert!(m.tiles.is_empty() && m.swipe.is_none() && m.groups.is_empty() && m.basemap == s.map().basemap); assert!(m.layers.iter().all(|l| !l.labels.enabled && l.selection.is_empty())); // A sketch of each layer for the small disc: every feature kept, far fewer vertices. for g in &m.layers { let l = s.map().layers.iter().find(|l| l.name == g.name).unwrap(); let vertices = |l: &rustgis_map::Layer| l.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).map(|g| g.coord_count()).sum::(); assert_eq!(g.dataset.features.len(), l.dataset.features.len()); assert!(vertices(g) * 2 < vertices(l), "{}: {} of {} vertices", g.name, vertices(g), vertices(l)); } // An island far smaller than the tolerance keeps its own ring rather than collapsing; no ring // on the globe has under four points. let island = dir.join("island.geojson"); std::fs::write( &island, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[-40, -5], [-39.999, -5], [-39.999, -4.999], [-40, -4.999], [-40, -5]]]}}]}"#, ) .unwrap(); s.run("layer.addData", &json!({"path": island.display().to_string()})).unwrap(); let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); let m = s.project.maps.iter().find(|m| Some(m.name.as_str()) == r["locator"].as_str()).unwrap(); let rings = |name: &str| -> Vec { m.layers .iter() .filter(|l| l.name == name || name.is_empty()) .flat_map(|l| l.dataset.features.iter()) .filter_map(|f| match &f.geometry { Some(rustgis_data::Geometry::Polygon(polys)) => Some(polys.iter().flatten().map(Vec::len).collect::>()), _ => None, }) .flatten() .collect() }; assert_eq!(rings("island"), [5]); assert!(rings("").iter().all(|n| *n >= 4)); // Sheets with ever more source lines, so ever smaller frames, down to the smallest a sheet // takes: the legend is never drawn over the locator, whichever corners they take (the search // keeps them apart), and if the locator leaves it no room it is left out, said. for n in 1..80 { let sources: Vec = (0..n).map(|i| format!("Source {i}: a line long enough to take a line of its own")).collect(); let Ok(r) = s.run("layout.publication", &json!({"profile": "a4-landscape", "extent": piaui, "sources": sources})) else { break }; let l = s.project.layouts.last().unwrap(); let named = |name: &str| l.elements.iter().find(|e| e.name == name); let locator = named("Locator").unwrap(); match named("Legend Box") { Some(b) => { let apart = b.x >= locator.x + locator.w - 1e-6 || locator.x >= b.x + b.w - 1e-6 || b.y >= locator.y + locator.h - 1e-6 || locator.y >= b.y + b.h - 1e-6; assert!(apart, "{n} lines: the legend over the locator"); } None => { let said = r["warnings"] .as_array() .unwrap() .iter() .any(|w| w.as_str().unwrap_or("").contains("the frame leaves the legend no room beside the locator")); assert!(said, "{n} lines: {r}"); } } } // The sheets above may never leave the legend that little room (the search keeps it off the // locator's side), so the rule itself: short of the arrow or the scale bar it keeps 10 mm, short // of the locator under 10 mm it is left out. use crate::cmd::layout::{Surround, legend_room}; assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, None, None), Some((40.0, 30.0))); assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Arrow, 70.0)), Some((Surround::Locator, 60.0))), Some((30.0, 20.0))); assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Arrow, 95.0)), Some((Surround::Scale, 75.0))), Some((10.0, 10.0))); assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, None, Some((Surround::Locator, 75.0))), None); assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Locator, 95.0)), None), None); let _ = std::fs::remove_dir_all(&dir); } /// Arrangements of a publication layout's surrounds (#1379): by name, corner by corner, the /// favourite (`layout.arrangement`, kept by the app) and `best`, the default, which keeps the /// favourite unless another keeps more surrounds in their own corners. Bad ones are refused before /// anything changes. #[test] fn publication_arrangements_and_the_favourite() { let dir = std::env::temp_dir().join(format!("rustgis-arrangement-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let square = |name: &str, (w, so, e, n): (f64, f64, f64, f64)| { let path = dir.join(format!("{name}.geojson")); let ring = format!("[[{w},{so}],[{e},{so}],[{e},{n}],[{w},{n}],[{w},{so}]]"); std::fs::write( &path, format!(r#"{{"type": "FeatureCollection", "features": [{{"type": "Feature", "properties": {{"name": "{name}"}}, "geometry": {{"type": "Polygon", "coordinates": [{ring}]}}}}]}}"#), ) .unwrap(); path.display().to_string() }; let corner_of = |s: &Session, name: &str| { let l = s.project.layouts.last().unwrap(); let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); let e = l.elements.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("no {name}")); let (right, low) = (e.x + e.w / 2.0 > frame.x + frame.w / 2.0, e.y + e.h / 2.0 > frame.y + frame.h / 2.0); match (low, right) { (false, false) => "top left", (false, true) => "top right", (true, false) => "bottom left", (true, true) => "bottom right", } }; let corners = |s: &Session| ["Locator", "North Arrow", "Legend Box", "Scale Box"].map(|n| corner_of(s, n)); let extent = json!([0, 0, 10, 10]); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": square("middle", (4.0, 4.0, 6.0, 6.0))})).unwrap(); // Nothing asked: classic, as before there was a choice. let r = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); assert_eq!( (r["arrangement"].as_str(), corners(&s)), (Some("classic"), ["top left", "top right", "bottom left", "bottom right"]), "{r}" ); // By name, and corner by corner. let r = s.run("layout.publication", &json!({"extent": extent, "author": "", "arrangement": "legendRight"})).unwrap(); assert_eq!(corners(&s), ["top left", "top right", "bottom right", "bottom left"], "{r}"); let custom = json!({"locator": "bottom-left", "northArrow": "top-left", "legend": "top-right", "scaleBar": "bottom-right"}); let r = s.run("layout.publication", &json!({"extent": extent, "author": "", "arrangement": custom})).unwrap(); assert_eq!((r["arrangement"].as_str(), corners(&s)), (Some("custom"), ["bottom left", "top left", "top right", "bottom right"]), "{r}"); // Corner names, like arrangement names, are read whatever their case. let shouted = json!({"locator": "Bottom-Left", "northArrow": "TOP-LEFT", "legend": "top-Right", "scaleBar": "bottom-right"}); let r = s.run("layout.publication", &json!({"extent": extent, "author": "", "arrangement": shouted})).unwrap(); assert_eq!(corners(&s), ["bottom left", "top left", "top right", "bottom right"], "{r}"); // The favourite: shown, set, then the one the default and "favourite" take. let shown = s.run("layout.arrangement", &json!({})).unwrap(); assert_eq!((shown["favourite"].as_str(), shown["corners"]["legend"].as_str()), (Some("classic"), Some("bottom-left"))); let set = s.run("layout.arrangement", &json!({"favourite": "mirrored"})).unwrap(); assert_eq!(set["favourite"], "mirrored"); for p in [json!({"extent": extent, "author": ""}), json!({"extent": extent, "author": "", "arrangement": "favourite"})] { let r = s.run("layout.publication", &p).unwrap(); assert_eq!( (r["arrangement"].as_str(), corners(&s)), (Some("mirrored"), ["top right", "top left", "bottom right", "bottom left"]), "{r}" ); } // Refused, naming what is wrong, with nothing changed. let layouts = s.project.layouts.len(); for (bad, why) in [ (json!("tidy"), "arrangement is \"classic\""), ( json!({"locator": "top-left", "northArrow": "top-left", "legend": "bottom-left", "scaleBar": "bottom-right"}), "a corner of its own", ), (json!({"locator": "top-left", "arrow": "top-right", "legend": "bottom-left", "scaleBar": "bottom-right"}), "not \"arrow\""), (json!({"locator": "top-left", "northArrow": "middle", "legend": "bottom-left", "scaleBar": "bottom-right"}), "\"northArrow\" is"), (json!(3), "arrangement is"), ] { let e = s.run("layout.publication", &json!({"extent": extent, "arrangement": bad})).unwrap_err(); assert!(e.contains(why), "{bad}: {e}"); let e = s.run("layout.arrangement", &json!({"favourite": bad})).unwrap_err(); assert!(e.contains(why), "{bad}: {e}"); } assert_eq!((s.project.layouts.len(), s.layout_favourite.as_str()), (layouts, Some("mirrored"))); // Best: data filling the top right, where the favourite (mirrored) has the locator at home; // classic has its small north arrow there and moves only that, less than mirrored's locator, so // it wins. "favourite" keeps mirrored, the locator off the data. let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": square("corner", (5.0, 5.0, 30.0, 30.0))})).unwrap(); s.run("layout.arrangement", &json!({"favourite": "mirrored"})).unwrap(); let best = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); assert_eq!(best["arrangement"], "classic", "{best}"); assert_eq!(corner_of(&s, "Locator"), "top left", "{best}"); let fav = s.run("layout.publication", &json!({"extent": extent, "author": "", "arrangement": "favourite"})).unwrap(); assert_eq!(fav["arrangement"], "mirrored", "{fav}"); assert_ne!(corner_of(&s, "Locator"), "top right", "{fav}"); let _ = std::fs::remove_dir_all(&dir); } /// `best` never wins by leaving the legend out (#1379): on sheets ever smaller, once classic's /// locator leaves the legend under it no room, the default still keeps a legend, in an arrangement /// that has room for it. #[test] fn best_keeps_the_legend_an_arrangement_would_leave_out() { let mut s = world(); let piaui = json!([-46.8, -11.6, -38.5, -2.2]); let dropped = "the frame leaves the legend no room beside the locator"; let mut checked = false; for n in 1..80 { let sources: Vec = (0..n).map(|i| format!("Source {i}: a line long enough to take a line of its own")).collect(); let p = json!({"profile": "a4-landscape", "extent": piaui, "sources": sources, "arrangement": "classic"}); let Ok(r) = s.run("layout.publication", &p) else { break }; if !r["warnings"].to_string().contains(dropped) { continue; } let best = s.run("layout.publication", &json!({"profile": "a4-landscape", "extent": piaui, "sources": sources})).unwrap(); let l = s.project.layouts.last().unwrap(); assert!(l.elements.iter().any(|e| e.name == "Legend Box"), "{n} lines: {best}"); assert!(!best["warnings"].to_string().contains(dropped), "{n} lines: {best}"); checked = true; break; } assert!(checked, "no sheet left classic's legend out"); } /// How `best` picks among the arrangements tried (#1379): the least area moved, within a square /// millimetre, then the least left over the data, within one, the first tried on a tie; the bands /// are measured from the best value, so they don't add up from one try to the next. #[test] fn best_picks_by_area_moved_then_area_over_the_data() { use crate::cmd::layout::pick_arrangement; // Each moves under a millimetre more than the one before, and leaves less over the data: the // third moves 1.8 mm² more than the first, out of the band, so the second wins. assert_eq!(pick_arrangement(&[(10.0, 50.0), (10.9, 40.0), (11.8, 30.0)]), Some(1)); // Clearly less moved wins whatever it leaves over the data. assert_eq!(pick_arrangement(&[(100.0, 0.0), (20.0, 90.0)]), Some(1)); // Ties go to the first tried (the favourite), within a millimetre either way. assert_eq!(pick_arrangement(&[(10.0, 20.0), (10.5, 19.5), (9.8, 20.4)]), Some(0)); assert_eq!(pick_arrangement(&[(f64::NAN, 1.0), (5.0, 5.0)]), Some(1)); assert_eq!(pick_arrangement(&[(f64::NAN, f64::NAN)]), None); assert_eq!(pick_arrangement(&[]), None); // Surrounds left over each other count ten times their overlap in the area moved. use crate::cmd::layout::overlap_area; assert_eq!(overlap_area(&[(0.0, 0.0, 10.0, 10.0), (5.0, 5.0, 10.0, 10.0), (30.0, 0.0, 5.0, 5.0)]), 25.0); assert_eq!(overlap_area(&[(0.0, 0.0, 10.0, 10.0), (10.0, 0.0, 10.0, 10.0)]), 0.0); } /// The legend is placed first (#1379): with data everywhere but the top right, it leaves its own /// covered corner for the free quarter, the north arrow's, and the two don't overlap. #[test] fn the_legend_is_placed_before_the_smaller_surrounds() { let dir = std::env::temp_dir().join(format!("rustgis-legend-first-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("l.geojson"); let square = |(w, so, e, n): (f64, f64, f64, f64)| format!("[[[{w},{so}],[{e},{so}],[{e},{n}],[{w},{n}],[{w},{so}]]]"); std::fs::write( &path, format!( r#"{{"type": "FeatureCollection", "features": [{{"type": "Feature", "properties": {{}}, "geometry": {{"type": "MultiPolygon", "coordinates": [{}, {}]}}}}]}}"#, square((-20.0, -20.0, 30.0, 5.0)), square((-20.0, 5.0, 5.0, 30.0)) ), ) .unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": "", "arrangement": "classic", "locator": "none"})).unwrap(); let l = s.project.layouts.last().unwrap(); let rect = |name: &str| l.elements.iter().find(|e| e.name == name).map(|e| (e.x, e.y, e.w, e.h)).unwrap(); let (frame, legend, arrow) = (rect("Map Frame"), rect("Legend Box"), rect("North Arrow")); let moved = r["moved"].as_array().unwrap(); assert!(moved.contains(&json!("legend")), "{r}"); assert!(!r["warnings"].to_string().contains("overlap"), "{r}"); // The legend in the free quarter, the top right. assert!(legend.0 + legend.2 / 2.0 > frame.0 + frame.2 / 2.0 && legend.1 + legend.3 / 2.0 < frame.1 + frame.3 / 2.0, "{r}"); // The arrow off it. let apart = legend.0 + legend.2 <= arrow.0 || arrow.0 + arrow.2 <= legend.0 || legend.1 + legend.3 <= arrow.1 || arrow.1 + arrow.3 <= legend.1; assert!(apart, "{r} {legend:?} {arrow:?}"); let _ = std::fs::remove_dir_all(&dir); } /// Where a surround that leaks off its corner goes (#1379): the free part of the frame, clear of /// the other surrounds, in line with them, near its corner; nowhere when it can't fit. #[test] fn a_leaking_surround_finds_the_free_part_of_the_frame() { use crate::cmd::layout::free_spot; let area = (0.0, 0.0, 100.0, 100.0); // Data over the left half (a continent), the frame's right half free (an ocean). let left_half = |(x, _, w, _): (f64, f64, f64, f64)| ((50.0 - x).clamp(0.0, w)) / w; let home = (2.0, 70.0, 20.0, 20.0); let r = free_spot(home, area, &[], left_half).unwrap(); assert!(left_half(r) == 0.0 && r.0 >= 50.0, "{r:?}"); // Near its corner: at the data's edge, and 10 mm lower, its bottom on the frame's (in line // with it costs less than the 10 mm away). assert_eq!(r, (50.0, 80.0, 20.0, 20.0)); // Another surround there: it keeps 2 mm clear of it, and lines up with its edge. let legend = (50.0, 60.0, 40.0, 38.0); let r = free_spot(home, area, &[legend], left_half).unwrap(); let clear = r.1 + r.3 + 2.0 <= legend.1 || r.0 >= legend.0 + legend.2 + 2.0; assert!(clear && left_half(r) == 0.0, "{r:?}"); assert!([legend.0, legend.0 + legend.2].contains(&r.0) || [legend.0, legend.0 + legend.2].contains(&(r.0 + r.2)), "in line: {r:?}"); // The same frame, the same place. assert_eq!(free_spot(home, area, &[legend], left_half), Some(r)); // Too big for the frame, or nowhere clear: none. assert_eq!(free_spot((0.0, 0.0, 120.0, 10.0), area, &[], left_half), None); assert_eq!(free_spot(home, area, &[(0.0, 0.0, 100.0, 100.0)], left_half), None); } /// A publication layout's surrounds keep off the map's data (#682, part 3): with a polygon filling /// the bottom left of the frame, the surround each arrangement puts there leaves it for a free place /// in the frame, the smallest to move winning: here the classic legend of one row (#1379); with /// data everywhere, nothing moves and nothing is said; with none, they stay in their own corners. `"decimal": ","` gives the layout a decimal comma, which /// `layout.page` also sets. #[test] fn publication_surrounds_keep_off_the_data() { let dir = std::env::temp_dir().join(format!("rustgis-placement-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let square = |name: &str, (w, so, e, n): (f64, f64, f64, f64)| { let path = dir.join(format!("{name}.geojson")); let ring = format!("[[{w},{so}],[{e},{so}],[{e},{n}],[{w},{n}],[{w},{so}]]"); std::fs::write( &path, format!(r#"{{"type": "FeatureCollection", "features": [{{"type": "Feature", "properties": {{"name": "{name}"}}, "geometry": {{"type": "Polygon", "coordinates": [{ring}]}}}}]}}"#), ) .unwrap(); path.display().to_string() }; let corner_of = |s: &Session, name: &str| { let l = s.project.layouts.last().unwrap(); let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); let e = l.elements.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("no {name}")); let (right, low) = (e.x + e.w / 2.0 > frame.x + frame.w / 2.0, e.y + e.h / 2.0 > frame.y + frame.h / 2.0); match (low, right) { (false, false) => "top left", (false, true) => "top right", (true, false) => "bottom left", (true, true) => "bottom right", } }; let extent = json!([0, 0, 10, 10]); // No data in any corner: each in its own. let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": square("middle", (4.0, 4.0, 6.0, 6.0))})).unwrap(); let r = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); let corners: Vec<_> = ["Locator", "North Arrow", "Legend Box", "Scale Box"].iter().map(|n| corner_of(&s, n)).collect(); assert_eq!(corners, ["top left", "top right", "bottom left", "bottom right"], "{r}"); assert!(r["warnings"].as_array().unwrap().is_empty() && r["moved"] == json!([]), "{r}"); // A polygon filling the bottom left: the legend keeps out of it. let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": square("corner", (-20.0, -20.0, 5.0, 5.0))})).unwrap(); // The coverage is drawn on the map itself, its basemap set aside meanwhile and put back. // Web tiles, a background like the basemap, are set aside too: they don't make every corner // look covered (a closed port, never asked). s.map_mut().basemap = rustgis_map::Basemap::Ocean; s.map_mut().tiles.push(rustgis_map::TileLayer::new("Streets", rustgis_map::TileKind::Raster, "http://127.0.0.1:9/{z}/{x}/{y}.png")); let r = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); assert!(s.map().basemap == rustgis_map::Basemap::Ocean && s.map().label_lock.is_none() && s.map().tiles.len() == 1); assert_ne!(corner_of(&s, "Legend Box"), "bottom left", "{r}"); s.map_mut().tiles.clear(); // Four surrounds, three free corners: the legend of one row is smaller to move than the scale // bar or the locator other arrangements put there, so classic wins; its legend leaves the // covered corner for a free place, out of the data's quarter, and nothing is left to say (#1379). assert_eq!((r["arrangement"].as_str(), &r["moved"]), (Some("classic"), &json!(["legend"])), "{r}"); assert!(!r["warnings"].to_string().contains("on what the map shows"), "{r}"); // A pale fill with no outline counts as data too: the legend leaves it the same way. let mut pale = Session::new(Project::default()); pale.run("layer.addData", &json!({"path": square("pale", (-20.0, -20.0, 5.0, 5.0))})).unwrap(); pale.run("layer.color", &json!({"layer": "pale", "color": "#FAFAF5"})).unwrap(); pale.run("layer.outline", &json!({"layer": "pale", "color": "#00000000", "width": 0})).unwrap(); let rp = pale.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); assert_ne!(corner_of(&pale, "Legend Box"), "bottom left", "{rp}"); s.run("layout.export", &json!({"path": dir.join("corner.png").display().to_string(), "dpi": 72})).unwrap(); // Data everywhere (a context layer filling the frame): nowhere better, so nothing said and each // in its own corner. let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": square("all", (-30.0, -30.0, 40.0, 40.0))})).unwrap(); let r = s.run("layout.publication", &json!({"extent": extent, "author": "", "decimal": ","})).unwrap(); assert!(!r["warnings"].to_string().contains("on what the map shows") && r["moved"] == json!([]), "{r}"); assert_eq!(corner_of(&s, "Legend Box"), "bottom left"); // A decimal comma, set by the command and by layout.page; anything else refused. assert!(s.project.layouts.last().unwrap().decimal_comma); let r = s.run("layout.page", &json!({"decimal": "."})).unwrap(); assert_eq!(r["decimal"], "."); assert!(!s.project.layouts.last().unwrap().decimal_comma); for p in [json!({"decimal": ";"}), json!({"decimal": 1})] { assert!(s.run("layout.page", &p).unwrap_err().contains("decimal is"), "{p}"); assert!(s.run("layout.publication", &json!({"extent": extent, "decimal": p["decimal"]})).unwrap_err().contains("decimal is")); } s.run("layout.export", &json!({"path": dir.join("comma.png").display().to_string(), "dpi": 72})).unwrap(); let _ = std::fs::remove_dir_all(&dir); } /// A publication legend sized to its rows fits them: its box, less the padding, may come out a /// rounding error short of the rows' width, which isn't "more than fits" (macOS CI, whose fonts /// gave one such width). Layer names of many widths, so some land on a rounding error with any font. #[test] fn a_publication_legend_sized_to_its_rows_fits_them() { let dir = std::env::temp_dir().join(format!("rustgis-legend-fit-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let names: Vec = (0..300).map(|i| format!("{}{i}", &"WmilQxkaRb"[i % 10..])).collect(); for name in &names { let path = dir.join(format!("{name}.geojson")); std::fs::write( &path, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4,4],[6,4],[6,6],[4,6],[4,4]]]}}]}"#, ) .unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": ""})).unwrap(); assert!(!r["warnings"].to_string().contains("more than fits"), "{name}: {r}"); } let _ = std::fs::remove_dir_all(&dir); } /// A publication legend too long for its corner goes into columns, the fewest that hold it (#1379), /// and past three says what it leaves out; a short one stays in one, saved as it always was. /// The largest rectangle with nothing drawn, inside the part of the grid asked for. #[test] fn the_largest_free_rectangle_of_a_grid() { use crate::cmd::layout::largest_free_cells; let grid = |rows: &[&str]| -> Vec { rows.iter().flat_map(|r| r.chars().map(|c| c == '#')).collect() }; let cells = grid(&["....#.", "....#.", "##..#.", "##...."]); assert_eq!(largest_free_cells(6, &cells, (0, 0, 6, 4)), (4, 2)); // Inside a part of it: the columns and rows outside don't count. assert_eq!(largest_free_cells(6, &cells, (2, 2, 6, 4)), (2, 2)); // A wide run under a taller narrow one, and the other way round. assert_eq!(largest_free_cells(3, &grid(&["#.#", "#.#", "..."]), (0, 0, 3, 3)), (1, 3)); assert_eq!(largest_free_cells(4, &grid(&["#..#", "....", "...."]), (0, 0, 4, 3)), (4, 2)); // All drawn, all free, nothing asked for. assert_eq!(largest_free_cells(2, &grid(&["##", "##"]), (0, 0, 2, 2)), (0, 0)); assert_eq!(largest_free_cells(2, &grid(&["..", ".."]), (0, 0, 2, 2)), (2, 2)); assert_eq!(largest_free_cells(2, &grid(&["..", ".."]), (1, 1, 1, 1)), (0, 0)); } /// A long legend takes the fewest columns that fit the largest rectangle the map leaves free: data /// along the bottom of the frame leaves a wide band above it, too low for one column. Layers that /// only outline their polygons don't take that room. #[test] fn a_publication_legend_takes_the_columns_that_fit_the_free_rectangle() { let dir = std::env::temp_dir().join(format!("rustgis-legend-free-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let polygon = |name: &str, [w, s, e, n]: [f64; 4]| { let path = dir.join(name); let json = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]]}}]}); std::fs::write(&path, json.to_string()).unwrap(); path.display().to_string() }; let low = polygon("low.geojson", [0.0, 0.0, 10.0, 6.5]); let border = polygon("border.geojson", [1.0, 7.0, 9.0, 9.0]); let columns = |outline: bool| { let mut s = Session::new(Project::default()); for _ in 0..24 { s.run("layer.addData", &json!({"path": low})).unwrap(); } if outline { s.run("layer.addData", &json!({"path": border})).unwrap(); s.run("layer.color", &json!({"layer": "border", "color": "#00000000"})).unwrap(); } let r = s .run("layout.publication", &json!({"profile": "a4-portrait", "extent": [0, 0, 10, 10], "author": "", "locator": "none"})) .unwrap(); (r["legendColumns"].as_u64().unwrap(), s.map().layers.iter().all(|l| l.visible)) }; assert_eq!(columns(false), (2, true)); assert_eq!(columns(true), (2, true), "the border doesn't count, and is shown again"); let _ = std::fs::remove_dir_all(&dir); } /// `"colorbar": "none"` leaves a raster's colorbar out of a publication, and its room in the /// legend's box with it: the box is the legend's height plus its padding (#1680). An unknown /// choice is refused. #[test] fn a_publication_without_a_colorbar_sizes_the_legend_box_to_the_legend() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = Session::new(Project::default()); // A classified raster (four values, each a class), over a polygon layer the legend lists. let (w, h) = (20u32, 20u32); let z: Vec = (0..w * h).map(|i| (i % 4) as f32 + 1.0).collect(); let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 0.5, pixel_height: -0.5 }; let r = Raster::new("classes", w, h, vec![z], gt, Crs::LonLat, None, ColorHint::Gray, "Byte").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "classes", rustgis_map::RasterLayer::from_bytes(r, "classes.tif".to_string(), bytes)).unwrap(); s.run("raster.symbology", &json!({"type": "unique"})).unwrap(); let path = std::env::temp_dir().join(format!("rustgis-1680-{}.geojson", std::process::id())); std::fs::write( &path, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4,4],[6,4],[6,6],[4,6],[4,4]]]}}]}"#, ) .unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let boxes = |s: &mut Session, p: Value| { let r = s.run("layout.publication", &p).unwrap(); let l = s.project.layouts.last().unwrap(); let el = |name: &str| l.elements.iter().find(|e| e.name == name).map(|e| e.h); (el("Legend Box"), el("Legend"), el("Colorbar"), r) }; let base = json!({"profile": "a4-portrait", "extent": [0, 0, 10, 10], "author": "", "locator": "none"}); let (bx, legend, bar, r) = boxes(&mut s, base.clone()); assert!(bar.is_some(), "{r}"); assert!(bx.unwrap() > legend.unwrap() + 4.0 + 10.0, "room for the colorbar: {r}"); let with_bar = bx.unwrap(); let mut none = base.clone(); none["colorbar"] = json!("none"); let (bx, legend, bar, r) = boxes(&mut s, none); assert_eq!(bar, None, "{r}"); assert!((bx.unwrap() - legend.unwrap() - 4.0).abs() < 1e-6, "{bx:?} {legend:?}: {r}"); // A legend stretched to its box would pass the line above; the box itself is smaller. assert!(bx.unwrap() < with_bar, "{bx:?} against {with_bar}: {r}"); let mut bad = base; bad["colorbar"] = json!("hidden"); assert!(s.run("layout.publication", &bad).unwrap_err().contains("colorbar is")); let _ = std::fs::remove_file(&path); } /// A smaller scale whose legend can't be measured (a streamed layer shown only there, whose file is /// gone) isn't tried, and the result says so. #[test] fn a_publication_says_a_smaller_scale_whose_legend_cant_be_measured() { let dir = std::env::temp_dir().join(format!("rustgis-unmeasured-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (fill, gone) = (dir.join("fill.geojson"), dir.join("gone.shp")); let square = |w: f64, s0: f64, e: f64, n: f64| { json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"k": "a"}, "geometry": {"type": "Polygon", "coordinates": [[[w, s0], [e, s0], [e, n], [w, n], [w, s0]]]}}]}) }; std::fs::write(&fill, square(0.0, 0.0, 15.0, 10.0).to_string()).unwrap(); // A shapefile: its legend is read from the whole source, a chunk at a time. { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::Coord; let mut d = Dataset::new("gone", GeometryType::Polygon, vec![Field::new("k", FieldType::Text)]); let ring = [(1.0, 1.0), (14.0, 1.0), (14.0, 9.0), (1.0, 9.0), (1.0, 1.0)]; d.push(Some(Geometry::Polygon(vec![vec![ring.map(|(x, y)| Coord::new(x, y)).to_vec()]])), vec![Value::Text("a".into())]); rustgis_data::io::write_path(&d, &gone).unwrap(); } let mut s = Session::new(Project::default()); for _ in 0..12 { s.run("layer.addData", &json!({"path": fill.display().to_string()})).unwrap(); } let p = json!({"profile": "a4-landscape", "extent": [5, 4, 8.7, 6], "author": "", "locator": "none"}); let asked = s .run( "layout.publication", &json!({"profile": "a4-landscape", "extent": [5, 4, 8.7, 6], "author": "", "locator": "none", "veil": false}), ) .unwrap()["scale"] .as_f64() .unwrap(); let id = s.run("layer.addData", &json!({"path": gone.display().to_string(), "stream": true, "name": "Gone"})).unwrap()["layer"] .as_u64() .unwrap(); // Drawn by classes, which the legend reads the whole source for; shown only at scales smaller // than the one asked for; then its files are gone. s.run("layer.symbology", &json!({"layer": id, "type": "unique", "field": "k"})).unwrap(); s.run("layer.scaleRange", &json!({"layer": id, "max": asked * 1.1})).unwrap(); for ext in ["shp", "shx", "dbf"] { let _ = std::fs::remove_file(gone.with_extension(ext)); } assert!(!gone.exists(), "the shapefile is gone"); let r = s.run("layout.publication", &p).unwrap(); let said = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).any(|w| w.contains("isn't moved or veiled at 1:")); assert!(said, "the scale not tried is said: {r}"); assert!(r["warnings"].to_string().contains("can't be read through"), "and why: {r}"); let _ = std::fs::remove_dir_all(&dir); } /// A moved frame is drawn at its own centre's ground scale (Web Mercator's changes with the /// latitude), so the extent drawn there is where the search placed it: whole in the frame, under /// its outline, north and south of the equator. #[test] fn a_publication_map_moved_off_its_latitude_draws_the_extent_where_it_was_placed() { use rustgis_geom::{Coord, Extent}; let dir = std::env::temp_dir().join(format!("rustgis-move-latitude-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (name, fill, extent) in [ ("north", [-30.0, 35.0, 70.0, 82.0], [10.0, 57.0, 30.0, 61.0]), ("south", [-75.0, -40.0, -25.0, 5.0], [-52.0, -23.0, -40.0, -19.0]), ] { let path = dir.join(format!("{name}.geojson")); let [w, s0, e, n] = fill; let ring = json!([[[w, s0], [e, s0], [e, n], [w, n], [w, s0]]]); let fc = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": ring}}]}); std::fs::write(&path, fc.to_string()).unwrap(); let mut s = Session::new(Project::default()); for _ in 0..30 { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } // A streamed column of points down the middle, every quarter degree. let lon = (extent[0] + extent[2]) / 2.0; let column: Vec = (0..).map(|i| s0 + 0.25 * f64::from(i)).take_while(|lat| *lat <= n).collect(); let points = dir.join(format!("{name}_points.geojson")); let features: Vec = column .iter() .map(|lat| json!({"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [lon, lat]}})) .collect(); std::fs::write(&points, json!({"type": "FeatureCollection", "features": features}).to_string()).unwrap(); s.run("layer.addData", &json!({"path": points.display().to_string(), "stream": true, "name": "Column"})).unwrap(); let r = s.run("layout.publication", &json!({"profile": "a4-portrait", "extent": extent, "author": "", "locator": "none"})).unwrap(); // Both move far up or down, off the asked centre's latitude, and are veiled. assert!(r["mapShift"][1].as_f64().unwrap().abs() > 40.0 && r["veiled"] == json!(true), "{name}: moved up or down, veiled: {r}"); let l = s.project.layouts.last().unwrap(); let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); let rustgis_map::layout::ElementKind::MapFrame { center, scale, .. } = &frame.kind else { panic!("{name}: the Map Frame is no map frame") }; let pt = rustgis_map::layout::PT_PER_MM; let cam = rustgis_map::layout::frame_camera(s.map(), *center, *scale, 0.0, 0.0); // Every streamed point the frame draws was read before the search drew it. let held: Vec = s .map() .layers .iter() .find(|l| l.name == "Column") .unwrap() .dataset .features .iter() .filter_map(|f| f.geometry.as_ref()) .map(|g| g.extent().min_y) .collect(); for lat in &column { let Some(m) = s.map().to_map(Coord::new(lon, *lat)) else { continue }; let (_, sy) = cam.to_screen(m, frame.w * pt, frame.h * pt); if (0.0..=frame.h * pt).contains(&sy) { assert!(held.contains(lat), "{name}: the point at {lat}°, {:.1} mm down the frame, isn't read: {held:?}", sy / pt); } } // The extent's edges as the frame draws them, in mm on the page. let asked = Extent::new(extent[0], extent[1], extent[2], extent[3]); let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for i in 0..=32 { let t = f64::from(i) / 32.0; let (x, y) = (asked.min_x + asked.width() * t, asked.min_y + asked.height() * t); for c in [Coord::new(x, asked.min_y), Coord::new(x, asked.max_y), Coord::new(asked.min_x, y), Coord::new(asked.max_x, y)] { let Some(m) = s.map().to_map(c) else { continue }; let (sx, sy) = cam.to_screen(m, frame.w * pt, frame.h * pt); (x0, y0, x1, y1) = (x0.min(sx / pt), y0.min(sy / pt), x1.max(sx / pt), y1.max(sy / pt)); } } let drawn = (frame.x + x0, frame.y + y0, frame.x + x1, frame.y + y1); assert!( drawn.0 >= frame.x - 0.5 && drawn.1 >= frame.y - 0.5 && drawn.2 <= frame.x + frame.w + 0.5 && drawn.3 <= frame.y + frame.h + 0.5, "{name}: the extent drawn at {drawn:?} isn't whole in the frame ({}, {}, {}, {}): {r}", frame.x, frame.y, frame.w, frame.h ); // The outline follows the extent as drawn, but where the margins clip it. { let o = l.elements.iter().find(|e| e.name == "Extent Outline").unwrap_or_else(|| panic!("{name}: no Extent Outline: {r}")); let margin = 15.0; for (side, at, want, edge) in [ ("left", o.x, drawn.0, frame.x), ("top", o.y, drawn.1, frame.y), ("right", o.x + o.w, drawn.2, frame.x + frame.w), ("bottom", o.y + o.h, drawn.3, frame.y + frame.h), ] { assert!( (at - want).abs() <= 0.5 || (at - edge).abs() <= margin, "{name}: the outline's {side} at {at:.1}, the extent's at {want:.1}: {r}" ); } } eprintln!("{name}: shift {} veiled {} scale {}", r["mapShift"], r["veiled"], r["scale"]); } let _ = std::fs::remove_dir_all(&dir); } /// A moved frame's coverage cut from one drawn wide enough for every move is the coverage of the /// moved frame drawn on its own, give or take the cells along the edges of what it draws. #[test] fn a_moved_frames_coverage_is_cut_from_the_wide_one() { use crate::cmd::layout::crop_cells; use crate::render::layout::frame_coverage; use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::Coord; let mut map = rustgis_map::Map::new("m"); let mut d = Dataset::new("shape", GeometryType::Polygon, Vec::new()); // An L, so a cut from the wrong side or the wrong way up doesn't match. let ring = [(-0.3, -0.3), (0.4, -0.3), (0.4, -0.1), (-0.1, -0.1), (-0.1, 0.35), (-0.3, 0.35), (-0.3, -0.3)]; d.push(Some(Geometry::Polygon(vec![vec![ring.map(|(x, y)| Coord::new(x, y)).to_vec()]])), Vec::new()); map.add_dataset(d); let (scale, (fw, fh), (sx, sy)): (f64, (f64, f64), (f64, f64)) = (1_000_000.0, (60.0, 40.0), (20.0, 15.0)); let wide = (fw + 2.0 * sx, fh + 2.0 * sy); let centre = Coord::new(0.0, 0.0); let cells = (384.0 * wide.0.max(wide.1) / fw.max(fh)).round() as u32; let whole = frame_coverage(&mut map, centre, scale, wide.0, wide.1, cells).unwrap(); let flat = map.flat(); let at = flat.forward_checked(centre).unwrap(); let mm = 1000.0 * flat.ground_per_unit(at) * map.body().ratio() / scale; for (dx, dy) in [(sx, 0.0), (-sx, 0.0), (0.0, sy), (0.0, -sy)] { let cut = crop_cells(&whole, wide, (sx - dx, sy - dy), (fw, fh)); let there = flat.inverse(Coord::new(at.x - dx / mm, at.y + dy / mm)).unwrap(); let own = frame_coverage(&mut map, there, scale, fw, fh, 384).unwrap(); assert_eq!((cut.0, cut.1), (own.0, own.1), "the same grid"); let drawn = own.2.iter().filter(|c| **c).count(); let differ = cut.2.iter().zip(&own.2).filter(|(a, b)| a != b).count(); assert!(drawn > 100 && differ * 10 < drawn, "moved by {dx}, {dy}: {differ} of {drawn} cells differ"); } // A frame reaching past the wide page is held inside it. let held = crop_cells(&whole, wide, (-50.0, 1e9), (fw, fh)); assert_eq!(held.2.len(), (held.0 * held.1) as usize); } /// The move search draws the map wider than its frame, at the asked scale too, and a streamed /// layer is read for all of that: read for the frame alone (half as wide again on each side, in /// degrees), it misses what a move shows past that, as in Web Mercator far from the equator. #[test] fn a_publication_reads_streamed_layers_for_the_move_searchs_wide_drawing() { let dir = std::env::temp_dir().join(format!("rustgis-wide-read-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (fill, points) = (dir.join("fill.geojson"), dir.join("far.geojson")); let square = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[-20.0, 10.0], [60.0, 10.0], [60.0, 85.0], [-20.0, 85.0], [-20.0, 10.0]]]}}]}); std::fs::write(&fill, square.to_string()).unwrap(); let point = |lat: f64| json!({"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [20.0, lat]}}); std::fs::write(&points, json!({"type": "FeatureCollection", "features": [point(60.0), point(17.0)]}).to_string()).unwrap(); let mut s = Session::new(Project::default()); for _ in 0..30 { s.run("layer.addData", &json!({"path": fill.display().to_string()})).unwrap(); } s.run("layer.addData", &json!({"path": points.display().to_string(), "stream": true, "name": "Far"})).unwrap(); let p = json!({"profile": "a4-portrait", "extent": [0, 58, 40, 62], "author": "", "locator": "none", "veil": false}); let r = s.run("layout.publication", &p).unwrap(); let far = s.map().layers.iter().find(|l| l.name == "Far").unwrap(); // The frame (180 × 243.5 mm at 1:16,000,000) is read down to 20°N; the wide drawing, with the // room to move the extent up, reaches about 13°N. let held: Vec = far.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).map(|g| g.extent().min_y).collect(); assert!(held.contains(&17.0), "the point at 17°N, in the wide drawing, is read: {held:?} {r}"); let _ = std::fs::remove_dir_all(&dir); } /// A veiled map's surrounds group in a band: beside each other in a wide one, one over the other in /// a tall one, nowhere when they don't fit. #[test] fn a_veil_panel_groups_the_surrounds_by_the_bands_shape() { use crate::cmd::layout::veil_panel; let (legend, arrow, scale) = ((80.0, 50.0), (8.0, 12.0), (60.0, 10.0)); let (l, locator, a, b) = veil_panel((0.0, 0.0, 160.0, 70.0), legend, Some(40.0), arrow, scale, 3.0).unwrap(); assert!(l.0 + l.2 <= locator.unwrap().0, "beside it: {l:?} {locator:?}"); assert!(a.0 + a.2 <= 160.0 && b.1 >= locator.unwrap().1 + 40.0, "{a:?} {b:?}"); let apart = |p: (f64, f64, f64, f64), q: (f64, f64, f64, f64)| p.0 + p.2 <= q.0 || q.0 + q.2 <= p.0 || p.1 + p.3 <= q.1 || q.1 + q.3 <= p.1; assert!(apart(a, l) && apart(a, (locator.unwrap().0, locator.unwrap().1, 40.0, 40.0)), "the arrow clear: {a:?} {l:?} {locator:?}"); let (l, locator, _, b) = veil_panel((0.0, 0.0, 90.0, 130.0), legend, Some(40.0), arrow, scale, 3.0).unwrap(); assert!(l.1 >= b.1 + b.3 && locator.unwrap().0 == b.0, "under it: {l:?} {b:?}"); assert!(veil_panel((0.0, 0.0, 60.0, 60.0), legend, Some(40.0), arrow, scale, 3.0).is_none()); assert!(veil_panel((0.0, 0.0, 160.0, 70.0), legend, None, arrow, scale, 3.0).unwrap().1.is_none()); } /// With no room for any shape of the legend on either side of a centred map, the map moves towards /// an edge, the asked extent still in the frame, and the legend takes the room that joins. With room /// already, it stays centred. #[test] fn a_publication_map_moves_aside_for_a_legend_with_no_room() { let dir = std::env::temp_dir().join(format!("rustgis-map-shift-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("a_layer_name.geojson"); let json = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[0.0, 0.0], [15.0, 0.0], [15.0, 10.0], [0.0, 10.0], [0.0, 0.0]]]}}]}); std::fs::write(&path, json.to_string()).unwrap(); let mut checked = 0; let laid = |layers: usize, extent: Value| { let mut s = Session::new(Project::default()); for _ in 0..layers { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } let r = s.run("layout.publication", &json!({"profile": "a4-landscape", "extent": extent, "author": "", "locator": "none"})).unwrap(); let said = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).find(|w| w.contains("in its frame")).map(String::from); (r["legendColumns"].as_u64().unwrap(), r["mapShift"][0].as_f64().unwrap(), r["mapShift"][1].as_f64().unwrap(), said) }; let (_, x, y, said) = laid(3, json!([-5, -5, 20, 15])); assert_eq!((x, y), (0.0, 0.0), "a short legend has room"); assert_eq!(said, None, "a map left centred isn't said to move"); let (columns, x, y, said) = laid(30, json!([0, 0, 15, 10])); assert!(x.abs() > 5.0 && y == 0.0, "moved sideways: {x}, {y}"); let said = said.expect("a move is said"); assert!(said.contains(&format!("{:.0} mm {}", x.abs(), if x > 0.0 { "right" } else { "left" })), "{said}"); assert_eq!(columns, 1, "one column beside the map"); // A map running on past the extent: what is outside it is veiled, at the asked scale while the // frame leaves a band of it wide enough, and at a smaller one otherwise. let on_past = |extent: Value, veil: Value| { let mut s = Session::new(Project::default()); for _ in 0..12 { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } let p = json!({"profile": "a4-landscape", "extent": extent, "author": "", "locator": "none", "veil": veil}); let r = s.run("layout.publication", &p).unwrap(); let smaller = r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("the map is drawn at 1:")); let veils = r["elements"].as_array().unwrap().iter().filter(|e| e["name"].as_str().unwrap().starts_with("Veil")).count(); assert_eq!(r["arrangement"] == "veil", r["veiled"].as_bool().unwrap(), "{r}"); let said = r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("outside the extent is veiled")); assert_eq!(said, r["veiled"].as_bool().unwrap(), "the veil is said, and only then: {r}"); // The surrounds grouped in the veil's band left their corners, and "moved" names them. if r["veiled"] == json!(true) { assert!(!r["moved"].as_array().unwrap().is_empty(), "the grouped surrounds are named as moved: {r}"); } (r["veiled"].as_bool().unwrap(), smaller, veils > 0, r["scale"].as_f64().unwrap()) }; let (veiled, smaller, veils, _) = on_past(json!([7, 4, 9, 6]), Value::Null); assert_eq!((veiled, smaller, veils), (true, false, true), "a narrow extent in a wide frame"); let (veiled, smaller, veils, scale) = on_past(json!([5, 4, 8.7, 6]), Value::Null); assert_eq!((veiled, smaller, veils), (true, true, true), "an extent the frame's shape"); // `"veil": false` keeps the map as it is drawn: no veil, and the scale it was asked at. let (veiled, smaller, veils, asked) = on_past(json!([5, 4, 8.7, 6]), json!(false)); assert_eq!((veiled, smaller, veils), (false, false, false)); assert!(asked < scale, "1:{asked} against the veil's 1:{scale}"); assert!(on_past(json!([7, 4, 9, 6]), json!(true)).0, "true, as by default"); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let bad = s.run("layout.publication", &json!({"profile": "a4-landscape", "veil": "no"})).unwrap_err(); assert!(bad.contains("veil is true or false"), "{bad}"); // A figure labels only its left and bottom edges, so its margins differ side to side: a move // keeps the extent inside each side's own margin, off the graticule's labels, whatever the // other side's. for (profile, extent) in [("figure-2col", json!([7, 4, 9, 6])), ("figure-2col", json!([6, 4, 9, 6])), ("thesis-half", json!([7, 4, 9, 6]))] { let mut s = Session::new(Project::default()); for _ in 0..12 { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } let r = s.run("layout.publication", &json!({"profile": profile, "extent": extent, "author": ""})).unwrap(); let l = s.project.layouts.last().unwrap(); let el = |name: &str| l.elements.iter().find(|e| e.name == name).map(|e| (e.x, e.y, e.w, e.h)); // An unveiled map has no outline of its extent; a veiled one has both. if r["veiled"] == json!(false) { assert!(el("Extent Outline").is_none(), "{profile} {extent}: an outline with no veil: {r}"); continue; } let (Some(frame), Some(outline)) = (el("Map Frame"), el("Extent Outline")) else { panic!("{profile} {extent}: veiled, with no Map Frame or Extent Outline: {r}"); }; let (left, bottom) = (2.0 + 9.0, 2.0 + 6.0); assert!(outline.0 >= frame.0 + left - 0.5, "{profile} {extent}: {outline:?} in the left labels' room of {frame:?}: {r}"); assert!( outline.1 + outline.3 <= frame.1 + frame.3 - bottom + 0.5, "{profile} {extent}: {outline:?} in the bottom labels' room: {r}" ); // The veil stops at the graticule's labels, so they stay as dark as on an unveiled map. for veil in l.elements.iter().filter(|e| e.name.starts_with("Veil")) { assert!( veil.x >= frame.0 + left - 1e-6 && veil.y + veil.h <= frame.1 + frame.3 - bottom + 1e-6, "{profile}: {} over the labels: {:?} in {frame:?}", veil.name, (veil.x, veil.y, veil.w, veil.h) ); } checked += 1; } assert_eq!(checked, 2, "both figures of the narrow extent are veiled"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_publication_legend_too_long_goes_into_columns() { use rustgis_map::layout::ElementKind; let dir = std::env::temp_dir().join(format!("rustgis-legend-columns-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("a.geojson"); std::fs::write( &path, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4,4],[6,4],[6,6],[4,6],[4,4]]]}}]}"#, ) .unwrap(); // Filling the frame below 60% of its height: a long legend in one column would cover it, and two // sit above it. A short one stays in one, whatever it covers. let low = dir.join("low.geojson"); std::fs::write( &low, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[0,0],[10,0],[10,6],[0,6],[0,0]]]}}]}"#, ) .unwrap(); // Labels too long for two columns side by side: they close up, and it says so. let mut s = Session::new(Project::default()); for i in 0..60 { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); s.run( "layer.rename", &json!({"layer": "a", "name": format!("{i} a layer's name long enough to take more than half of the frame's width")}), ) .unwrap(); } let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": "", "locator": "none"})).unwrap(); assert!(r["legendColumns"].as_u64().unwrap() > 1, "{r}"); assert!(r["warnings"].to_string().contains("run into the next"), "{r}"); for (layers, columns) in [(3, 1), (30, 2)] { let mut s = Session::new(Project::default()); for _ in 0..layers { s.run("layer.addData", &json!({"path": low.display().to_string()})).unwrap(); } let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": "", "locator": "none"})).unwrap(); assert_eq!(r["legendColumns"], columns, "{layers} layers: {r}"); } for (layers, columns, cut) in [(3, 1, false), (60, 3, false), (100, 3, false), (200, 3, true)] { let mut s = Session::new(Project::default()); for _ in 0..layers { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": "", "locator": "none"})).unwrap(); assert_eq!(r["legendColumns"], columns, "{layers} layers: {r}"); assert_eq!(r["warnings"].to_string().contains("more than fits"), cut, "{layers} layers: {r}"); let l = s.project.layouts.last().unwrap(); let legend = l.elements.iter().find(|e| matches!(e.kind, ElementKind::Legend { .. })).unwrap(); assert!(matches!(legend.kind, ElementKind::Legend { columns: c, .. } if c == columns), "{:?}", legend.kind); assert_eq!(serde_json::to_string(&legend.kind).unwrap().contains("columns"), columns > 1); } let _ = std::fs::remove_dir_all(&dir); } /// A legend that one column shows whole, in a free rectangle wider than the legend's usual box, stays /// in one: a second column would only widen the box. A long one too, in a rectangle taller than that /// box. #[test] fn a_publication_legend_that_fits_whole_stays_in_one_column() { let dir = std::env::temp_dir().join(format!("rustgis-legend-whole-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let polygon = |name: &str, [w, s, e, n]: [f64; 4]| { let path = dir.join(name); let json = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]]}}]}); std::fs::write(&path, json.to_string()).unwrap(); path.display().to_string() }; let low = polygon("low.geojson", [0.0, 0.0, 10.0, 6.0]); let mut s = Session::new(Project::default()); for name in ["first", "second", "a layer's name long enough to take a little over the share of the frame's width a legend starts with"] { s.run("layer.addData", &json!({"path": low})).unwrap(); s.run("layer.rename", &json!({"layer": "low", "name": name})).unwrap(); } let p = json!({"profile": "a4-landscape", "extent": [0, 0, 10, 10], "author": "", "locator": "none"}); let r = s.run("layout.publication", &p).unwrap(); assert_eq!(r["legendColumns"], 1, "{r}"); let l = s.project.layouts.last().unwrap(); let legend = l.elements.iter().find(|e| e.name == "Legend Box").unwrap(); let frame = l.element(l.first_frame().unwrap()).unwrap(); assert!(legend.w > 0.45 * frame.w, "the whole of its width: {r}"); assert!(!r["warnings"].to_string().contains("more than fits"), "{r}"); let left = polygon("left.geojson", [0.0, 0.0, 3.0, 10.0]); let mut s = Session::new(Project::default()); for _ in 0..28 { s.run("layer.addData", &json!({"path": left})).unwrap(); } let p = json!({"profile": "a4-landscape", "extent": [0, 0, 10, 10], "author": "", "locator": "none"}); let r = s.run("layout.publication", &p).unwrap(); assert_eq!(r["legendColumns"], 1, "{r}"); let l = s.project.layouts.last().unwrap(); let legend = l.elements.iter().find(|e| e.name == "Legend Box").unwrap(); let frame = l.element(l.first_frame().unwrap()).unwrap(); assert!(legend.h > 0.6 * frame.h, "the whole of its height: {r}"); assert!(!r["warnings"].to_string().contains("more than fits"), "{r}"); let _ = std::fs::remove_dir_all(&dir); } /// A legend left out isn't in any columns, whatever the frame leaves free. #[test] fn a_publication_legend_left_out_has_one_column() { let dir = std::env::temp_dir().join(format!("rustgis-legend-out-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("a.geojson"); std::fs::write( &path, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4.8,4.8],[5.2,4.8],[5.2,5.2],[4.8,5.2],[4.8,4.8]]]}}]}"#, ) .unwrap(); let mut checked = false; for layers in [6, 8, 10, 12, 16, 20, 30, 40, 80] { let mut s = Session::new(Project::default()); for _ in 0..layers { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } for n in 1..120 { let sources: Vec = (0..n).map(|i| format!("Source {i}: a line long enough to take a line of its own")).collect(); let p = json!({"profile": "a4-landscape", "extent": [0, 0, 10, 10], "sources": sources, "arrangement": "classic"}); let Ok(r) = s.run("layout.publication", &p) else { break }; if r["warnings"].to_string().contains("the frame leaves the legend no room beside the locator") { assert_eq!(r["legendColumns"], 1, "{layers} layers, {n} lines: {r}"); checked = true; } } } assert!(checked, "no sheet left the legend out"); let _ = std::fs::remove_dir_all(&dir); } /// A publication legend takes another column to show more of itself, or as much and cover at least /// a quarter less of what the map draws. #[test] fn more_columns_win_by_showing_more_or_covering_less() { use crate::cmd::layout::more_columns_win; assert!(more_columns_win((1.0, 500.0), (0.6, 0.0)), "more shown, whatever it covers"); assert!(!more_columns_win((0.6, 0.0), (1.0, 500.0)), "less shown, whatever it covers"); assert!(more_columns_win((1.0, 300.0), (1.0, 500.0)), "as much shown, covering 40% less"); assert!(!more_columns_win((1.0, 400.0), (1.0, 500.0)), "only 20% less"); assert!(!more_columns_win((1.0, 0.0), (1.0, 0.0)), "nothing to gain"); } /// `"columns"` on a legend: given and changed, refused outside 1 to 3 and on anything else. #[test] fn a_legend_takes_columns_from_one_to_three() { use rustgis_map::layout::ElementKind; let mut s = Session::new(Project::default()); s.run("layout.new", &json!({"template": "map"})).unwrap(); let columns = |s: &Session, id: u64| match s.project.layouts[0].element(id as u32).map(|e| &e.kind) { Some(ElementKind::Legend { columns, .. }) => *columns, other => panic!("{other:?}"), }; let added = s.run("layout.addElement", &json!({"kind": "legend"})).unwrap(); let id = added["element"].as_u64().unwrap(); assert_eq!(columns(&s, id), 1); let two = s.run("layout.addElement", &json!({"kind": "legend", "columns": 2})).unwrap(); assert_eq!(columns(&s, two["element"].as_u64().unwrap()), 2); s.run("layout.update", &json!({"element": id, "columns": 3})).unwrap(); assert_eq!(columns(&s, id), 3); s.run("layout.update", &json!({"element": id, "title": "Key"})).unwrap(); assert_eq!(columns(&s, id), 3, "another change leaves them"); for bad in [json!(0), json!(4), json!(1.5), json!("2"), json!(-1)] { assert!(s.run("layout.update", &json!({"element": id, "columns": bad})).unwrap_err().contains("1 to 3"), "{bad}"); assert!(s.run("layout.addElement", &json!({"kind": "legend", "columns": bad})).unwrap_err().contains("1 to 3"), "{bad}"); } assert_eq!(columns(&s, id), 3); let err = s.run("layout.addElement", &json!({"kind": "northArrow", "columns": 2})).unwrap_err(); assert!(err.contains("only legends"), "{err}"); let arrow = s.run("layout.addElement", &json!({"kind": "northArrow"})).unwrap(); let err = s.run("layout.update", &json!({"element": arrow["element"], "columns": 2})).unwrap_err(); assert!(err.contains("only legends"), "{err}"); let err = s.run("layout.update", &json!({"element": 999, "columns": 2})).unwrap_err(); assert!(err.contains("no element 999"), "{err}"); // A layout saved before them has one. let old: ElementKind = serde_json::from_str(r#"{"Legend":{"frame":1,"title":"L"}}"#).unwrap(); assert!(matches!(old, ElementKind::Legend { columns: 1, .. }), "{old:?}"); // A value out of range, from a file edited by hand, is saved as the nearest. for (given, kept) in [(0, 1), (999, 3)] { let mut l = s.project.layouts[0].clone(); let Some(ElementKind::Legend { columns, .. }) = l.element_mut(id as u32).map(|e| &mut e.kind) else { panic!() }; *columns = given; l.clamp(); assert!(matches!(l.element(id as u32).map(|e| &e.kind), Some(ElementKind::Legend { columns, .. }) if *columns == kept), "{given}"); } } /// A legend given `"columns"` says when its box doesn't hold them, and only then. #[test] fn a_legends_columns_say_when_its_box_is_too_small() { let dir = std::env::temp_dir().join(format!("rustgis-legend-box-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("a.geojson"); std::fs::write( &path, r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4,4],[6,4],[6,6],[4,6],[4,4]]]}}]}"#, ) .unwrap(); let mut s = Session::new(Project::default()); for _ in 0..12 { s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); } s.run("layout.new", &json!({"template": "map"})).unwrap(); let said = |s: &Session, mark: u64| s.notices_since(mark).iter().map(|n| n.text.clone()).collect::>().join(" | "); // One big enough: nothing to say, added or changed. let mark = s.notice_mark(); let added = s.run("layout.addElement", &json!({"kind": "legend", "columns": 2, "x": 10, "y": 10, "w": 150, "h": 150})).unwrap(); let id = added["element"].as_u64().unwrap(); s.run("layout.update", &json!({"element": id, "columns": 3})).unwrap(); s.run("layout.update", &json!({"element": id, "columns": 1})).unwrap(); assert_eq!(said(&s, mark), ""); // Too short: its last rows are left out. let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "h": 15, "columns": 2})).unwrap(); assert!(said(&s, mark).contains("its last rows are left out"), "{}", said(&s, mark)); // Too narrow: its columns close up. let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "w": 12, "h": 150, "columns": 3})).unwrap(); assert!(said(&s, mark).contains("they close up"), "{}", said(&s, mark)); // Added too small, in one column. let mark = s.notice_mark(); s.run("layout.addElement", &json!({"kind": "legend", "columns": 1, "x": 10, "y": 10, "w": 150, "h": 15})).unwrap(); assert!(said(&s, mark).contains("its last rows or the ends of long labels are left out"), "{}", said(&s, mark)); // No title leaves its rows the height it would take. let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "w": 150, "h": 150, "title": "", "columns": 2})).unwrap(); assert_eq!(said(&s, mark), ""); // In columns, its box or what it lists changing without "columns": told, and told nothing when // it still fits or only moves. let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "h": 15})).unwrap(); assert!(said(&s, mark).contains("its last rows are left out"), "{}", said(&s, mark)); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "x": 20})).unwrap(); assert_eq!(said(&s, mark), ""); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "allClasses": true})).unwrap(); assert!(said(&s, mark).contains("its last rows are left out"), "{}", said(&s, mark)); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "h": 150})).unwrap(); s.run("layout.update", &json!({"element": id, "hideOther": true})).unwrap(); assert_eq!(said(&s, mark), ""); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "w": 12})).unwrap(); assert!(said(&s, mark).contains("they close up"), "{}", said(&s, mark)); // In one column a change of its box says nothing, as before columns. s.run("layout.update", &json!({"element": id, "w": 150, "h": 150, "columns": 1})).unwrap(); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "h": 15})).unwrap(); assert_eq!(said(&s, mark), ""); // Nothing listed: nothing to lose, in a box shorter than its title too. let mut empty = Session::new(Project::default()); empty.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); empty.run("layout.new", &json!({"template": "map"})).unwrap(); let mark = empty.notice_mark(); let p = json!({"kind": "legend", "columns": 2, "exclude": ["a"], "x": 10, "y": 10, "w": 150, "h": 1}); empty.run("layout.addElement", &p).unwrap(); assert_eq!(said(&empty, mark), ""); // A frame whose map is gone: said rather than left unchecked. s.project.maps[0].name = "Elsewhere".into(); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "columns": 2})).unwrap(); assert!(said(&s, mark).contains("shows no map of this project"), "{}", said(&s, mark)); let _ = std::fs::remove_dir_all(&dir); } /// A publication layout keys a raster theme with a colorbar: the topmost visible raster drawn on a /// ramp gets one at the bottom of the legend's box; a map of vectors only gets none. #[test] fn a_publication_layouts_raster_gets_a_colorbar() { use rustgis_map::layout::ElementKind; let dir = std::env::temp_dir().join(format!("rustgis-colorbar-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let tif = dir.join("ramp.tif"); std::fs::write(&tif, rustgis_data::raster::cog::example(256, 128)).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": tif.display().to_string()})).unwrap(); let r = s.run("layout.publication", &json!({"extent": [10, 45, 11, 46], "author": "", "locator": "none"})).unwrap(); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n); let (bar, then) = (named("Colorbar").unwrap_or_else(|| panic!("no colorbar: {r}")), named("Legend Box").unwrap()); let ElementKind::Colorbar { layer, title, .. } = &bar.kind else { panic!() }; assert_eq!((layer.as_deref(), title.as_str()), (Some("ramp"), "ramp")); assert!( bar.x >= then.x && bar.x + bar.w <= then.x + then.w + 1e-9 && bar.y + bar.h <= then.y + then.h + 1e-9, "inside the legend's box" ); s.run("layout.export", &json!({"path": dir.join("colorbar.png").display().to_string(), "dpi": 72})).unwrap(); // Smaller boxes (figures, a locator taking room): the colorbar stays wholly inside its box, never // above its top, and a box too small for it says so, not that legend rows were cut. for profile in ["figure-1col", "thesis-half", "a4-landscape"] { let r = s.run("layout.publication", &json!({"extent": [10, 45, 11, 46], "profile": profile, "locator": "globe"})).unwrap(); let l = s.project.layouts.last().unwrap(); let named = |n: &str| l.elements.iter().find(|e| e.name == n); let (Some(bar), Some(then)) = (named("Colorbar"), named("Legend Box")) else { continue }; let inside = bar.x >= then.x - 1e-9 && bar.y >= then.y - 1e-9 && bar.x + bar.w <= then.x + then.w + 1e-9 && bar.y + bar.h <= then.y + then.h + 1e-9; assert!(inside, "{profile}: the colorbar outside its box: {r}"); } // Vectors only: no colorbar. let mut s = world(); s.run("layout.publication", &json!({"extent": [10, 45, 11, 46]})).unwrap(); assert!(s.project.layouts.last().unwrap().elements.iter().all(|e| e.name != "Colorbar")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn layouts_compose_export_and_round_trip() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-layout-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 20_000_000})).unwrap(); let r = s.run("layout.new", &json!({"name": "Europe", "page": "a4", "orientation": "landscape"})).unwrap(); assert_eq!(r["layout"], 0); assert_eq!(s.active_layout, Some(0)); let l = &s.project.layouts[0]; assert!((l.width - 297.0).abs() < 1e-9 && l.elements.len() == 6, "the map template adds a frame and five surrounds"); let frame = l.first_frame().unwrap(); // Frames, surrounds and the rest. for shape in ["circle", "ellipse", "polygon", "lasso"] { s.run("layout.addFrame", &json!({"shape": shape, "points": [[0, 0], [1, 0], [0.5, 1]]})).unwrap(); } assert!(s.run("layout.addFrame", &json!({"shape": "polygon", "points": [[0, 0], [1, 0]]})).is_err()); let mut png = tiny_skia::Pixmap::new(4, 4).unwrap(); png.fill(tiny_skia::Color::from_rgba8(0, 128, 255, 255)); let b64 = base64::engine::general_purpose::STANDARD.encode(png.encode_png().unwrap()); for p in [ json!({"kind": "table", "layer": "Countries", "rows": 5}), json!({"kind": "chart", "layer": "Countries", "field": "pop_est", "label": "name"}), json!({"kind": "rectangle"}), json!({"kind": "dynamicText", "text": "Page {page} of {pages} · {scale}"}), json!({"kind": "picture", "image": b64}), json!({"kind": "grid", "step": 10}), ] { s.run("layout.addElement", &p).unwrap_or_else(|e| panic!("{p}: {e}")); } // A chart moved to a layer without its field charts that layer's first numeric field. let chart = s.project.layouts[0].elements.iter().find(|e| e.kind.code() == "chart").unwrap().id; s.run("layout.update", &json!({"element": chart, "layer": "Cities"})).unwrap(); let Some(rustgis_map::layout::ElementKind::Chart { layer, field, .. }) = s.project.layouts[0].element(chart).map(|e| &e.kind) else { panic!() }; let cities = &s.map().layer_by_name("Cities").unwrap().dataset; assert_eq!(layer, "Cities"); assert!(cities.field_index(field).is_some_and(|i| cities.fields[i].ty.is_numeric()), "{field}"); s.run("layout.update", &json!({"element": chart, "layer": "Countries", "field": "pop_est"})).unwrap(); let inset = s.run("layout.addFrame", &json!({"shape": "rectangle", "x": 200, "y": 150, "w": 60, "h": 40})).unwrap()["element"] .as_u64() .unwrap(); s.run("layout.update", &json!({"element": inset, "scale": 200_000_000})).unwrap(); s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": inset, "of": frame})).unwrap(); assert!(s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": inset, "of": inset})).is_err()); // Its colour and width (#213): red and 2 pt by default, set when added or later, and kept // through a save; `false` takes it away. let indicator = |s: &Session| match s.project.layouts[0].element(inset as u32).map(|e| &e.kind) { Some(rustgis_map::layout::ElementKind::MapFrame { extent_of, extent_color, extent_width, .. }) => { (*extent_of, extent_color.hex(), *extent_width) } _ => panic!("the inset is a map frame"), }; assert_eq!(indicator(&s), (Some(frame), "#D01C1C".to_string(), 2.0)); let saved = serde_json::to_string(&s.project.layouts[0]).unwrap(); assert!(!saved.contains("extent_color") && !saved.contains("extent_width"), "defaults aren't written"); s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": inset, "of": frame, "color": "#1F4E9A", "width": 0.75})) .unwrap(); assert_eq!(indicator(&s), (Some(frame), "#1F4E9A".to_string(), 0.75)); s.run("layout.update", &json!({"element": inset, "extentIndicator": {"width": 99}})).unwrap(); assert_eq!(indicator(&s).2, rustgis_map::layout::MAX_INDICATOR_WIDTH, "clamped, and the colour kept"); assert_eq!(indicator(&s).1, "#1F4E9A"); let saved: rustgis_map::layout::Layout = serde_json::from_str(&serde_json::to_string(&s.project.layouts[0]).unwrap()).unwrap(); assert!(saved.element(inset as u32).is_some_and( |e| matches!(&e.kind, rustgis_map::layout::ElementKind::MapFrame { extent_color, .. } if extent_color.hex() == "#1F4E9A") )); assert!(s.run("layout.update", &json!({"element": inset, "extentIndicator": {"color": "blue-ish"}})).is_err()); assert!(s.run("layout.update", &json!({"element": inset, "extentIndicator": 3})).is_err()); // A value given but not a colour or a number is refused, naming it, and changes nothing. for (bad, field) in [(json!({"width": "wide"}), "width"), (json!({"color": 123}), "color")] { let err = s.run("layout.update", &json!({"element": inset, "extentIndicator": bad})).unwrap_err(); assert!(err.starts_with(field), "{err}"); let mut add = json!({"kind": "extentIndicator", "frame": inset, "of": frame}); add[field] = bad[field].clone(); assert!(s.run("layout.addElement", &add).is_err_and(|e| e.starts_with(field))); } assert_eq!((indicator(&s).1.as_str(), indicator(&s).2), ("#1F4E9A", rustgis_map::layout::MAX_INDICATOR_WIDTH)); s.run("layout.update", &json!({"element": inset, "extentIndicator": false})).unwrap(); assert_eq!(indicator(&s).0, None); let err = s.run("layout.update", &json!({"element": inset, "extentIndicator": {"color": "#000000"}})).unwrap_err(); assert!(err.contains("no extent indicator"), "{err}"); s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": inset, "of": frame})).unwrap(); assert!(s.run("layout.addElement", &json!({"kind": "sparkles"})).is_err()); // A hairline rule keeps its height when added and when edited (#211). let rule = s.run("layout.addElement", &json!({"kind": "rectangle", "color": "#000000", "w": 136, "h": 0.15})).unwrap()["element"] .as_u64() .unwrap() as u32; assert_eq!(s.project.layouts[0].element(rule).map(|e| e.h), Some(0.15)); s.run("layout.update", &json!({"element": rule, "h": 0.35})).unwrap(); assert_eq!(s.project.layouts[0].element(rule).map(|e| e.h), Some(0.35)); let err = s.run("layout.addElement", &json!({"kind": "rectangle", "h": 0})).unwrap_err(); assert!(err.contains("more than 0"), "{err}"); // Frame zooms and edits. s.run("layout.frameZoom", &json!({"element": frame, "to": "layer", "layer": "Lakes"})).unwrap(); s.run("layout.update", &json!({"element": frame, "x": 12, "w": 180, "center": [10, 50]})).unwrap(); assert!(s.run("layout.update", &json!({"element": 9999, "x": 1})).is_err()); s.run("layout.update", &json!({"element": frame, "name": "Main map"})).unwrap(); assert!(s.run("layout.update", &json!({"element": frame, "x": 99, "center": "Paris"})).is_err()); assert!((s.project.layouts[0].element(frame).unwrap().x - 12.0).abs() < 1e-9, "a bad update changes nothing"); let chart = s.project.layouts[0].elements.iter().find(|e| matches!(e.kind, rustgis_map::layout::ElementKind::Chart { .. })).unwrap().id; assert!(s.run("layout.update", &json!({"element": chart, "field": "no_such_field"})).is_err()); assert!(s.run("layout.update", &json!({"element": chart, "layer": "Atlantis"})).is_err()); s.run("layout.update", &json!({"element": chart, "field": "gdp_md"})).unwrap(); assert_eq!(s.project.layouts[0].element(frame).unwrap().name, "Main map"); // A map series over the lakes: one page each. let r = s.run("layout.series", &json!({"layer": "Lakes", "nameField": "name"})).unwrap(); let pages = r["pages"].as_u64().unwrap(); assert!(pages > 5); let next = s.run("layout.seriesPage", &json!({"page": "next"})).unwrap(); assert_eq!(next["page"], 1); s.run("layout.seriesRefresh", &Value::Null).unwrap(); // A manual zoom of the series frame survives exports. s.run("layout.update", &json!({"element": frame, "scale": 3_000_000})).unwrap(); let scale_of = |s: &Session| match &s.project.layouts[0].element(frame).unwrap().kind { rustgis_map::layout::ElementKind::MapFrame { scale, .. } => *scale, _ => 0.0, }; // Export PNG and a PDF of every page. let png_path = dir.join("europe.png"); s.run("layout.export", &json!({"path": png_path.display().to_string(), "dpi": 72})).unwrap(); let bytes = std::fs::read(&png_path).unwrap(); assert!(bytes.starts_with(b"\x89PNG")); let pdf_path = dir.join("europe.pdf"); let mark = s.notice_mark(); s.run("layout.export", &json!({"path": pdf_path.display().to_string(), "dpi": 36})).unwrap(); let pdf = std::fs::read(&pdf_path).unwrap(); let count = format!("/Count {pages}"); assert!(pdf.windows(count.len()).any(|w| w == count.as_bytes()), "one PDF page per series page"); // Kept as vectors: its text in outline fonts, no page a picture, and nothing too large. let has = |needle: &[u8]| pdf.windows(needle.len()).any(|w| w == needle); assert!(has(b"/Subtype /Type3") && has(b"/ToUnicode") && has(b"/Contents")); assert!(!has(b"/Im0 Do"), "no page is one picture"); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("as pictures, not vectors"))); assert_eq!(s.project.layouts[0].series.as_ref().unwrap().page, 1, "the shown page is restored"); assert_eq!(scale_of(&s), 3_000_000.0, "and so is the frame's zoom"); // A format the export has no writer for is refused. assert!(s.run("layout.export", &json!({"path": dir.join("x.tiff").display().to_string()})).is_err()); // Layout files. let file = dir.join("europe.rgislayout"); s.run("layout.save", &json!({"path": file.display().to_string()})).unwrap(); s.run("layout.import", &json!({"path": file.display().to_string()})).unwrap(); assert_eq!(s.project.layouts.len(), 2); assert_eq!(s.project.layouts[1].name, "Europe 2"); assert!(s.run("layout.import", &json!({"text": "{}"})).is_err()); // Removing the frame removes its surrounds; undo brings them back. let before = s.project.layouts[1].elements.len(); let first = s.project.layouts[1].first_frame().unwrap(); s.run("layout.removeElement", &json!({"layout": 1, "element": first})).unwrap(); assert!(s.project.layouts[1].elements.len() < before - 3); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.layouts[1].elements.len(), before); // Layouts are saved with the project. let text = serde_json::to_string(&s.project).unwrap(); let p = crate::Project::from_json(&text).unwrap(); assert_eq!(p.layouts.len(), 2); let r = s.run("layout.rename", &json!({"name": "Europe 2", "newName": "europe"})).unwrap(); assert_eq!(r["name"], "europe 2", "renaming keeps names unique"); s.run("layout.rename", &json!({"name": "europe 2", "newName": "Europe 2"})).unwrap(); s.run("layout.close", &Value::Null).unwrap(); assert_eq!(s.active_layout, None); s.run("layout.remove", &json!({"name": "Europe 2"})).unwrap(); assert_eq!(s.project.layouts.len(), 1); let _ = std::fs::remove_dir_all(&dir); } #[test] fn custom_colour_ramps_add_apply_save_and_travel() { use rustgis_map::{Ramp, RasterRenderer, Renderer, Rgba}; let dir = std::env::temp_dir().join(format!("rustgis-ramps-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = |n: &str| dir.join(n).display().to_string(); let mut s = world(); let file = r##"[ {"name": "Phosphor Frostfire", "kind": "diverging", "stops": ["#eff6ff", "#7b5ca6", "#fff5da"]}, {"name": "Brand", "kind": "qualitative", "stops": ["#112233", "#445566"]} ]"##; let r = s.run("ramp.add", &json!({"text": file})).unwrap(); assert_eq!(r["added"], json!(["Phosphor Frostfire", "Brand"])); // One ramp on its own is a file too; the same name replaces it. s.run("ramp.add", &json!({"text": r##"{"name": "brand", "kind": "qualitative", "stops": ["#112233", "#445566", "#778899"]}"##})) .unwrap(); assert_eq!(s.project.ramps.len(), 2); for bad in [r##"{"name": "Ember", "stops": ["#000000", "#ffffff"]}"##, r##"[]"##, r##"{"name": "x", "stops": ["#000000"]}"##, "not json"] { assert!(s.run("ramp.add", &json!({"text": bad})).is_err(), "{bad}"); } assert_eq!(s.project.ramps.len(), 2, "a refused file adds nothing"); assert_eq!(s.run("project.connections", &Value::Null).unwrap()["ramps"], json!(["Phosphor Frostfire", "brand"])); // Three classes from a three-stop ramp take its colours as they are. s.run( "layer.symbology", &json!({"layer": "Cities", "type": "graduated", "field": "pop_max", "classes": 3, "ramp": "phosphor frostfire"}), ) .unwrap(); let fills = |s: &Session| match &s.map().layer_by_name("Cities").unwrap().renderer { Renderer::Graduated { symbols, ramp, .. } => (symbols.iter().map(|y| y.fill).collect::>(), ramp.name().to_string()), r => panic!("{r:?}"), }; let stops = vec![Rgba::rgb(0xef, 0xf6, 0xff), Rgba::rgb(0x7b, 0x5c, 0xa6), Rgba::rgb(0xff, 0xf5, 0xda)]; assert_eq!(fills(&s), (stops.clone(), "Phosphor Frostfire".to_string())); // Color Scheme recolours with a custom ramp too. s.run("layer.ramp", &json!({"layer": "Countries", "ramp": "Brand"})).unwrap(); assert!(s.run("layer.ramp", &json!({"layer": "Countries", "ramp": "Nope"})).is_err()); // Saved with the project, and inside the layer's renderer. let p = crate::Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert_eq!(p.ramps, s.project.ramps); // A layer file carries the ramp to a project that doesn't have it. s.run("layer.save", &json!({"layer": "Cities", "path": path("cities.rgislyr")})).unwrap(); s.run("ramp.save", &json!({"path": path("mine.rgisramp")})).unwrap(); s.run("ramp.remove", &json!({"name": "PHOSPHOR FROSTFIRE"})).unwrap(); assert!(s.run("ramp.remove", &json!({"name": "Phosphor Frostfire"})).is_err()); assert_eq!(fills(&s).0, stops, "removing a ramp leaves layers that use it alone"); // ...which can still be restyled with it, by name or by leaving the ramp out. let grad = json!({"layer": "Cities", "type": "graduated", "field": "pop_max", "classes": 3, "method": "quantile"}); let mut named = grad.clone(); named["ramp"] = json!("Phosphor Frostfire"); s.run("layer.symbology", &named).unwrap(); assert_eq!(fills(&s), (stops.clone(), "Phosphor Frostfire".to_string())); s.run("layer.symbology", &grad).unwrap(); assert_eq!(fills(&s).1, "Phosphor Frostfire", "a ramp left out is kept"); named["ramp"] = json!("Nope"); assert!(s.run("layer.symbology", &named).is_err(), "an unknown ramp is an error"); // A hand-edited project's repeated or built-in names are made unique, keeping every ramp. let mut p = s.project.clone(); p.ramps = serde_json::from_str( r##"[{"name": "Brand", "stops": ["#000000", "#ffffff"]}, {"name": "brand", "stops": ["#111111", "#ffffff"]}, {"name": "Ember", "stops": ["#222222", "#ffffff"]}, {"name": "jet", "stops": ["#333333", "#ffffff"]}]"##, ) .unwrap(); let jet = p.ramps.last().cloned().unwrap(); fn graduated(p: &mut Project) -> Option<&mut Renderer> { p.maps.iter_mut().flat_map(|m| m.layers.iter_mut()).find(|l| l.name == "Cities").map(|l| &mut l.renderer) } if let Some(Renderer::Graduated { ramp, .. }) = graduated(&mut p) { *ramp = Ramp::Custom(Box::new(jet.clone())); } let _ = p.normalize(); // The matplotlib library's names are built-in too: an older project's own "jet" is kept as // "jet (custom)", while layers keep the copy of it they hold. assert_eq!(p.ramps.iter().map(|r| r.name()).collect::>(), ["Brand", "brand 2", "Ember (custom)", "jet (custom)"]); assert!(matches!(graduated(&mut p), Some(Renderer::Graduated { ramp: Ramp::Custom(c), .. }) if **c == jet)); let mut t = world(); t.run("layer.addData", &json!({"path": path("cities.rgislyr")})).unwrap(); match &t.map().layer_by_name("Cities (2)").unwrap().renderer { Renderer::Graduated { symbols, ramp: Ramp::Custom(c), .. } => { assert_eq!(c.name(), "Phosphor Frostfire"); assert_eq!(symbols.iter().map(|y| y.fill).collect::>(), stops); } r => panic!("{r:?}"), } // The saved file adds them back. t.run("ramp.add", &json!({"path": path("mine.rgisramp")})).unwrap(); assert_eq!(t.project.ramps.len(), 2); assert!(Session::new(Project::default()).run("ramp.save", &json!({"path": path("none.rgisramp")})).is_err()); // Rasters: stretch and classify with a custom ramp. let mut r = with_raster(); r.run("ramp.add", &json!({"path": path("mine.rgisramp")})).unwrap(); r.run("raster.symbology", &json!({"type": "stretch", "ramp": "Phosphor Frostfire"})).unwrap(); let style = |r: &Session| r.map().layers.first().unwrap().raster.as_ref().unwrap().style.renderer.clone(); assert!(matches!(style(&r), RasterRenderer::Stretch { ramp: Ramp::Custom(_), .. })); r.run("raster.symbology", &json!({"type": "classify", "classes": 3})).unwrap(); assert!(matches!(style(&r), RasterRenderer::Classify { ramp: Ramp::Custom(_), .. }), "classify keeps the ramp"); r.run("ramp.remove", &json!({"name": "Phosphor Frostfire"})).unwrap(); r.run("raster.symbology", &json!({"type": "classify", "classes": 4, "ramp": "Phosphor Frostfire"})).unwrap(); assert!(matches!(style(&r), RasterRenderer::Classify { ramp: Ramp::Custom(_), .. }), "the raster's own copy is found by name"); assert!(r.run("raster.symbology", &json!({"type": "stretch", "ramp": "Nope"})).is_err()); assert!(crate::cmd::share::map_png(&mut r, Some(200.0), Some(150.0), None).unwrap().len() > 1000); let _ = std::fs::remove_dir_all(&dir); } #[test] fn colour_ramps_from_qgis_gdal_and_gmt_files() { use rustgis_map::color::RampKind; let dir = std::env::temp_dir().join(format!("rustgis-palettes-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = |n: &str| dir.join(n).display().to_string(); let mut s = world(); // A GMT palette by path: named after the file, with the kind given. std::fs::write(path("relief.cpt"), "-100 0/0/255 0 255/255/255\n0 255/255/255 100 255/0/0\n").unwrap(); let r = s.run("ramp.add", &json!({"path": path("relief.cpt"), "kind": "diverging"})).unwrap(); assert_eq!(r["added"], json!(["relief"])); assert_eq!(s.project.ramps[0].kind(), RampKind::Diverging); // GDAL colour-relief text, as the web app passes a picked file: its bytes and name. use base64::Engine; let data = base64::engine::general_purpose::STANDARD.encode("0, 0, 0, 0, 255\n10, 255, 255, 255, 255\nnv, 0, 0, 0, 0\n"); let r = s.run("ramp.add", &json!({"data": data, "file": "Grey.txt"})).unwrap(); assert_eq!(r["added"], json!(["Grey"])); // A QGIS style as text, with the format named. let xml = r#""#; s.run("ramp.add", &json!({"text": xml, "format": "qgis", "name": "From QGIS"})).unwrap(); assert_eq!(s.project.ramps.iter().map(|r| r.name()).collect::>(), ["relief", "Grey", "From QGIS"]); s.run("layer.ramp", &json!({"layer": "Countries", "ramp": "relief"})).unwrap(); for bad in [ json!({"text": xml, "format": "svg"}), json!({"text": xml, "kind": "round"}), json!({"text": r##"{"name": "Ember", "stops": ["#000000", "#ffffff"]}"##}), ] { assert!(s.run("ramp.add", &bad).is_err(), "{bad}"); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn project_items_files_styles_packages_tasks_and_3d() { let dir = std::env::temp_dir().join(format!("rustgis-items-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = |n: &str| dir.join(n).display().to_string(); let mut s = world(); // Folder connections. s.run("project.addFolder", &json!({"path": dir.display().to_string()})).unwrap(); assert!(s.run("project.addFolder", &json!({"path": path("missing")})).is_err()); assert_eq!(s.run("project.connections", &Value::Null).unwrap()["folders"][0]["available"], true); // Map and layer files. s.run("map.save", &json!({"path": path("world.rgismap")})).unwrap(); s.run("map.import", &json!({"path": path("world.rgismap")})).unwrap(); assert_eq!(s.project.maps.len(), 2); assert_eq!(s.project.maps[1].name, "World (2)"); s.run("layer.symbology", &json!({"layer": "Cities", "type": "single", "color": "#AA00FF"})).ok(); s.run("layer.save", &json!({"layer": "Cities", "path": path("cities.rgislyr")})).unwrap(); let n = s.map().layers.len(); s.run("layer.addData", &json!({"path": path("cities.rgislyr")})).unwrap(); assert_eq!(s.map().layers.len(), n + 1); assert!(s.map().layer_by_name("Cities (2)").is_some()); // Styles. s.run("style.new", &json!({"layer": "Countries", "name": "Choropleth"})).unwrap(); s.run("style.save", &json!({"path": path("styles.rgisstyle")})).unwrap(); s.run("style.remove", &json!({"name": "Choropleth"})).unwrap(); s.run("style.add", &json!({"path": path("styles.rgisstyle")})).unwrap(); assert!(s.run("style.apply", &json!({"name": "Choropleth", "layer": "Cities"})).is_err(), "polygon style on points"); s.run("layer.symbology", &json!({"layer": "Countries", "type": "single"})).unwrap(); s.run("style.apply", &json!({"name": "Choropleth", "layer": "Countries"})).unwrap(); assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer.kind_label(), "Graduated Colors"); let r = s.run("style.import", &json!({"path": path("world.rgismap")})).unwrap(); assert!(r["imported"].as_u64().unwrap() >= 4); // Templates keep the setup, not the features. s.run("project.saveTemplate", &json!({"path": path("atlas.rgistemplate")})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.fromTemplate", &json!({"path": path("atlas.rgistemplate"), "name": "From template"})).unwrap(); assert_eq!(t.project.name, "From template"); assert!(t.map().layer_by_name("Countries").unwrap().dataset.features.is_empty()); assert!(!t.project.styles.is_empty()); // Packages open again. s.run("project.package", &json!({"path": path("atlas.rgispkg")})).unwrap(); let mut o = Session::new(Project::default()); o.run("project.open", &json!({"path": path("atlas.rgispkg")})).unwrap(); assert_eq!(o.project.maps.len(), 2); assert!(o.path.is_none(), "a package opens unsaved"); // The desktop app's command line and a dropped file open it as a project too (#411). let pkg = path("atlas.rgispkg"); assert_eq!(crate::project::open_command(&pkg), "project.open"); let mut d = Session::new(Project::default()); d.run(crate::project::open_command(&pkg), &json!({"path": pkg})).unwrap(); assert_eq!(d.project.maps.len(), 2); assert!(d.path.is_none(), "a package opens unsaved, so Save asks where to write a project"); s.run("map.package", &json!({"path": path("world.rgismpk"), "mobile": true, "tolerance": 0.05})).unwrap(); s.run("map.import", &json!({"path": path("world.rgismpk")})).unwrap(); let full: usize = s.project.maps[0] .layer_by_name("Countries") .unwrap() .dataset .features .iter() .filter_map(|f| f.geometry.as_ref()) .map(|g| g.coord_count()) .sum(); let mobile: usize = s .project .maps .last() .unwrap() .layer_by_name("Countries") .unwrap() .dataset .features .iter() .filter_map(|f| f.geometry.as_ref()) .map(|g| g.coord_count()) .sum(); assert!(mobile < full, "mobile packages simplify: {mobile} < {full}"); assert!(s.run("gp.package", &json!({"path": path("results.rgisgpk")})).is_err(), "no runs yet"); s.run("map.activate", &json!({"name": "World"})).unwrap(); s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 100}})).unwrap(); s.run("gp.package", &json!({"path": path("results.rgisgpk")})).unwrap(); let entries = rustgis_data::zip::read(&std::fs::read(path("results.rgisgpk")).unwrap()).unwrap(); assert!(entries.iter().any(|(n, _)| n == "history.json") && entries.iter().any(|(n, _)| n.starts_with("results/"))); // Layer templates. let id = s.run("layer.template", &json!({"template": "places"})).unwrap()["layer"].as_u64().unwrap() as u32; assert_eq!(s.map().layer(id).unwrap().dataset.fields.len(), 4); assert!(s.run("layer.template", &json!({"template": "unicorns"})).is_err()); // Tasks. s.run("task.new", &json!({"name": "Explore", "steps": [{"title": "Zoom to Europe", "command": "map.goToXY", "params": {"lon": 10, "lat": 50, "scale": 20000000}}, {"title": "Select big countries", "command": "select.byAttributes", "params": {"layer": "Countries", "where": "pop_est > 50000000"}}]})).unwrap(); assert!(s.run("task.new", &json!({"steps": [{"title": "x", "command": "no.such"}]})).is_err()); let r = s.run("task.run", &json!({"task": "Explore"})).unwrap(); assert_eq!(r["ran"].as_array().unwrap().len(), 2); assert!(s.map().selection_count() > 5); s.run("task.addStep", &json!({"task": "Explore", "title": "Clear", "command": "select.clear"})).unwrap(); s.run("task.run", &json!({"task": "Explore", "step": 2})).unwrap(); assert_eq!(s.map().selection_count(), 0); s.run("task.new", &json!({"name": "Replay", "fromHistory": true})).unwrap(); assert!(s.item_trusted("task", "replay"), "history run in this session"); // History that came with an opened project is someone else's. s.history_from_file = 1; s.run("task.new", &json!({"name": "Replay file", "fromHistory": true})).unwrap(); assert!(!s.item_trusted("task", "replay file")); s.history_from_file = 0; assert_eq!(s.project.tasks.last().unwrap().steps.len(), 1); s.run("task.save", &json!({"task": "Explore", "path": path("explore.rgistask")})).unwrap(); s.run("task.import", &json!({"path": path("explore.rgistask")})).unwrap(); assert_eq!(s.project.tasks.last().unwrap().name, "Explore 2"); // A task file may not touch files or run scripts. let sneaky = json!({"name": "Sneaky", "steps": [{"title": "Overwrite", "command": "share.exportMap", "params": {"path": path("x.png")}}]}); assert!(s.run("task.import", &json!({"text": sneaky.to_string()})).is_err()); let nested = json!({"name": "Nested", "steps": [{"title": "Run", "command": "script.run", "params": {"script": "map.zoomFull"}}]}); assert!(s.run("task.import", &json!({"text": nested.to_string()})).is_err()); // A task arriving inside a project file is vetted when it runs. s.project.tasks.push(crate::project::Task { name: "From a file".into(), description: String::new(), steps: vec![crate::project::TaskStep { title: "Export".into(), instructions: String::new(), command: "share.exportMap".into(), params: json!({"path": path("y.png")}), }], }); assert!(s.run("task.run", &json!({"task": "From a file"})).is_err()); assert!(!std::path::Path::new(&path("y.png")).exists()); // A step naming a feature layer runs even when a raster is selected. s.run("task.new", &json!({"name": "Layered", "steps": [{"title": "Zoom", "command": "layer.zoomTo", "params": {"layer": "Lakes"}}]})) .unwrap(); s.run("task.run", &json!({"task": "Layered"})).unwrap(); // 3D export: polygons extruded by a field. s.run("share.export3d", &json!({"layer": "Countries", "path": path("countries.obj"), "heightField": "pop_est", "zScale": 0.00001})) .unwrap(); let obj = std::fs::read_to_string(path("countries.obj")).unwrap(); assert!(obj.lines().filter(|l| l.starts_with("v ")).count() > 1000 && obj.contains("\nf ")); // Caps are triangles, and every face refers to a written vertex. let verts = obj.lines().filter(|l| l.starts_with("v ")).count(); assert!(obj.lines().filter(|l| l.starts_with("f ")).all(|l| { let ids: Vec = l[2..].split_whitespace().map(|x| x.parse().unwrap()).collect(); (3..=4).contains(&ids.len()) && ids.iter().all(|i| (1..=verts).contains(i)) })); let (name, bytes) = crate::cmd::items::item_bytes(&mut s, "obj", &json!({"layer": "Cities"})).unwrap(); assert!(name.ends_with(".obj") && !bytes.is_empty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn data_design_rules_hold_on_every_edit() { let mut s = world(); s.run("layer.template", &json!({"template": "routes", "name": "Roads"})).unwrap(); s.run("edit.addField", &json!({"layer": "Roads", "name": "kind", "type": "long"})).unwrap(); s.run("edit.addField", &json!({"layer": "Roads", "name": "lanes", "type": "long"})).unwrap(); // Domains. s.run("data.domain", &json!({"layer": "Roads", "field": "surface", "coded": [["paved", "Paved"], ["gravel", "Gravel"]]})).unwrap(); s.run("data.domain", &json!({"layer": "Roads", "field": "lanes", "min": 1, "max": 8})).unwrap(); assert!(s.run("data.domain", &json!({"layer": "Roads", "field": "name", "min": 1, "max": 2})).is_err(), "text takes coded values"); let line = json!({"type": "LineString", "coordinates": [[0, 0], [1, 1]]}); s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"surface": "paved", "lanes": 2}})).unwrap(); assert!(s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"surface": "ice"}})).is_err()); assert!(s.run("edit.setValue", &json!({"layer": "Roads", "row": 0, "field": "lanes", "value": 12})).is_err()); assert_eq!(s.map().layer_by_name("Roads").unwrap().dataset.features.len(), 1, "the failed edits were undone"); // Subtypes fill defaults on new features. s.run("data.subtypes", &json!({"layer": "Roads", "field": "kind", "subtypes": [{"code": 1, "name": "Highway", "defaults": {"lanes": 4}}, {"code": 2, "name": "Street", "defaults": {"lanes": 1}}]})).unwrap(); assert!(s.run("data.subtypes", &json!({"layer": "Roads", "field": "name", "subtypes": [{"code": 1, "name": "x"}]})).is_err()); let r = s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 1}})).unwrap(); let row = r["row"].as_u64().unwrap() as usize; let lanes = |s: &Session, row: usize| { let l = s.map().layer_by_name("Roads").unwrap(); l.dataset.value(row, l.dataset.field_index("lanes").unwrap()).clone() }; assert_eq!(lanes(&s, row).as_f64(), Some(4.0)); // Attribute rules: a calculation and a constraint. s.run( "data.attributeRule", &json!({"layer": "Roads", "name": "Length", "kind": "calculation", "field": "length_km", "expression": "lanes * 10"}), ) .unwrap(); s.run( "data.attributeRule", &json!({"layer": "Roads", "name": "Named highways", "kind": "constraint", "expression": "kind <> 1 OR name IS NOT NULL"}), ) .unwrap(); assert!( s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 1}})).is_err(), "highways need names" ); let r = s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 1, "name": "A1"}})).unwrap(); let row = r["row"].as_u64().unwrap() as usize; let l = s.map().layer_by_name("Roads").unwrap(); assert_eq!(l.dataset.value(row, l.dataset.field_index("length_km").unwrap()).as_f64(), Some(40.0)); assert!(s.run("data.attributeRule", &json!({"layer": "Roads", "name": "Bad", "kind": "constraint", "expression": "(("})).is_err()); s.run("data.attributeRule", &json!({"layer": "Roads", "name": "Named highways", "remove": true})).unwrap(); // Deleting a field a rule uses drops the rule, so editing isn't blocked. s.run("edit.addField", &json!({"layer": "Roads", "name": "temp", "type": "double"})).unwrap(); s.run("data.attributeRule", &json!({"layer": "Roads", "name": "Temp", "kind": "constraint", "expression": "temp IS NULL OR temp > 0"})) .unwrap(); let r = s.run("edit.deleteField", &json!({"layer": "Roads", "field": "temp"})).unwrap(); assert_eq!(r["rulesRemoved"], 1); s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 2}})).unwrap(); // Contingent values. s.run("data.contingentValues", &json!({"layer": "Roads", "name": "Surface by kind", "fields": ["kind", "surface"], "combos": [[1, "paved"], [2, "paved"], [2, "gravel"]]})).unwrap(); assert!( s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 1, "surface": "gravel"}})).is_err() ); s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "attributes": {"kind": 2, "surface": "gravel"}})).unwrap(); // Templates. s.run("edit.template", &json!({"layer": "Roads", "name": "Gravel street", "defaults": {"kind": 2, "surface": "gravel"}})).unwrap(); let r = s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "template": "Gravel street"})).unwrap(); assert_eq!(lanes(&s, r["row"].as_u64().unwrap() as usize).as_f64(), Some(1.0), "template, then subtype defaults"); // Archive and restore. s.run("data.archive", &json!({"layer": "Roads"})).unwrap(); let t0 = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs_f64() - 0.001; let n = s.map().layer_by_name("Roads").unwrap().dataset.features.len(); s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line, "template": "Gravel street"})).unwrap(); s.run("edit.setValue", &json!({"layer": "Roads", "row": 0, "field": "name", "value": "Main"})).unwrap(); let h = s.run("data.archiveHistory", &json!({"layer": "Roads"})).unwrap(); assert_eq!(h["edits"], 2); s.run("data.archiveRestore", &json!({"layer": "Roads", "time": t0})).unwrap(); let l = s.map().layer_by_name("Roads").unwrap(); assert_eq!(l.dataset.features.len(), n); assert!(l.dataset.value(0, l.dataset.field_index("name").unwrap()).is_null()); // Edit status, and turning editing off. let st = s.run("edit.status", &Value::Null).unwrap(); assert!(st["layers"].as_array().unwrap().iter().any(|l| l["layer"] == "Roads" && l["added"].as_u64().unwrap() >= 5)); s.run("edit.enable", &json!({"on": false})).unwrap(); assert!(s.run("edit.addFeature", &json!({"layer": "Roads", "geometry": line})).is_err()); s.run("edit.enable", &json!({"on": true})).unwrap(); } #[test] fn split_refuses_a_line_with_a_latitude_past_the_poles() { let mut s = world(); s.run("layer.template", &json!({"template": "lines", "name": "Paths"})).unwrap(); let add = |s: &mut Session, coords: Value| { s.run("edit.addFeature", &json!({"layer": "Paths", "geometry": {"type": "LineString", "coordinates": coords}})).unwrap() }; add(&mut s, json!([[0, 88], [5, 88]])); let bad = add(&mut s, json!([[0, 88], [0, 95], [10, 88]]))["row"].as_u64().unwrap() as usize; let before = s.map().layer_by_name("Paths").unwrap().dataset.features.clone(); // Both lines are selected: the one past the pole is refused, and neither is split. s.run("select.rows", &json!({"layer": "Paths", "rows": [0, bad]})).unwrap(); assert!(s.run("edit.split", &json!({"lon": 2, "lat": 88})).is_err()); assert_eq!(s.map().layer_by_name("Paths").unwrap().dataset.features, before); // Without it, the same split works. s.run("select.rows", &json!({"layer": "Paths", "rows": [0]})).unwrap(); s.run("edit.split", &json!({"lon": 2, "lat": 88})).unwrap(); assert_eq!(s.map().layer_by_name("Paths").unwrap().dataset.features.len(), before.len() + 1); } #[test] fn modify_tools_snapping_topology_relationships_and_elevation() { let mut s = with_raster(); s.run("layer.template", &json!({"template": "lines", "name": "Paths"})).unwrap(); let add = |s: &mut Session, coords: Value| { s.run("edit.addFeature", &json!({"layer": "Paths", "geometry": {"type": "LineString", "coordinates": coords}})).unwrap()["row"] .as_u64() .unwrap() as usize }; let a = add(&mut s, json!([[0, 0], [1, 0]])); // Move one degree east (about 111 km at the equator), then rotate and scale. s.run("select.rows", &json!({"layer": "Paths", "rows": [a]})).unwrap(); s.run("edit.move", &json!({"dx": 111_319.5, "dy": 0})).unwrap(); let first = |s: &Session, row: usize| match s.map().layer_by_name("Paths").unwrap().dataset.features[row].geometry.as_ref().unwrap() { rustgis_data::Geometry::Line(p) => p[0][0], _ => panic!("not a line"), }; assert!((first(&s, a).x - 1.0).abs() < 1e-3, "{:?}", first(&s, a)); s.run("edit.rotate", &json!({"angle": 90})).unwrap(); s.run("edit.scale", &json!({"factor": 2})).unwrap(); s.run("edit.flip", &Value::Null).unwrap(); // Split, then merge back, then explode. let b = add(&mut s, json!([[10, 10], [12, 10]])); s.run("select.rows", &json!({"layer": "Paths", "rows": [b]})).unwrap(); s.run("edit.split", &json!({"lon": 11, "lat": 10.2})).unwrap(); let n = s.map().layer_by_name("Paths").unwrap().dataset.features.len(); s.run("select.rows", &json!({"layer": "Paths", "rows": [b, n - 1]})).unwrap(); // A merge the rules reject leaves the layer as it was. s.run("data.attributeRule", &json!({"layer": "Paths", "name": "Never", "kind": "constraint", "expression": "name = 'never'"})).unwrap(); let before = s.map().layer_by_name("Paths").unwrap().dataset.features.clone(); assert!(s.run("edit.merge", &Value::Null).is_err()); assert_eq!(s.map().layer_by_name("Paths").unwrap().dataset.features, before); s.run("data.attributeRule", &json!({"layer": "Paths", "name": "Never", "remove": true})).unwrap(); s.run("select.rows", &json!({"layer": "Paths", "rows": [b, n - 1]})).unwrap(); s.run("edit.merge", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Paths").unwrap().dataset.features.len(), n - 1); s.run("edit.explode", &Value::Null).unwrap(); assert_eq!(s.map().layer_by_name("Paths").unwrap().dataset.features.len(), n); assert!(s.run("edit.setGeometry", &json!({"layer": "Paths", "row": 0, "geometry": {"type": "Point", "coordinates": [0, 0]}})).is_err()); s.run("edit.setGeometry", &json!({"layer": "Paths", "row": 0, "geometry": {"type": "LineString", "coordinates": [[0, 0], [0, 1]]}})) .unwrap(); // Vertex editing (the Edit Vertices tool): insert, move and delete, one undo step each. let line = |s: &Session| match s.map().layer_by_name("Paths").unwrap().dataset.features[0].geometry.as_ref().unwrap() { rustgis_data::Geometry::Line(p) => p[0].iter().map(|c| [c.x, c.y]).collect::>(), _ => panic!("not a line"), }; let r = s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "insert", "index": 0, "lon": 0.5, "lat": 0.5})).unwrap(); assert_eq!(r["index"], 1, "the new vertex"); assert_eq!(line(&s), [[0.0, 0.0], [0.5, 0.5], [0.0, 1.0]]); s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "move", "index": 2, "lon": 1, "lat": 1})).unwrap(); assert_eq!(line(&s), [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]); s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "delete", "index": 1})).unwrap(); assert_eq!(line(&s), [[0.0, 0.0], [1.0, 1.0]]); assert!(s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "delete", "index": 1})).is_err(), "a line keeps two"); assert!(s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "move", "index": 7, "lon": 1, "lat": 1})).is_err()); assert!(s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "move", "index": 0, "lon": 1e9, "lat": 1})).is_err()); for bad in [json!(-1), json!("2"), json!(0.5)] { assert!( s.run("edit.vertex", &json!({"layer": "Paths", "row": 0, "action": "move", "part": bad, "index": 0, "lon": 1, "lat": 1})) .is_err(), "part {bad}" ); } s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(line(&s), [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]], "undo brings the deleted vertex back"); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(line(&s), [[0.0, 0.0], [0.5, 0.5], [0.0, 1.0]]); // Traverse: two legs from a start, ground to grid off and on. s.run("layer.select", &json!({"layer": "Paths"})).unwrap(); let r = s .run("edit.traverse", &json!({"start": [20, 0], "legs": [{"distance": 1000, "bearing": 90}, {"distance": 1000, "bearing": 0}]})) .unwrap(); let row = r["row"].as_u64().unwrap() as usize; let end = |s: &Session, row: usize| match s.map().layer_by_name("Paths").unwrap().dataset.features[row].geometry.as_ref().unwrap() { rustgis_data::Geometry::Line(p) => *p[0].last().unwrap(), _ => panic!("not a line"), }; let e = end(&s, row); assert!((e.x - 20.008983).abs() < 1e-5 && (e.y - 0.009043).abs() < 1e-4, "{e:?}"); s.run("edit.groundToGrid", &json!({"scale": 1.5, "rotation": 0})).unwrap(); let r = s.run("edit.traverse", &json!({"start": [20, 0], "legs": [{"distance": 1000, "bearing": 90}]})).unwrap(); assert!((end(&s, r["row"].as_u64().unwrap() as usize).x - 20.013475).abs() < 1e-5); assert!(s.run("edit.groundToGrid", &json!({"scale": 9})).is_err()); s.run("edit.groundToGrid", &json!({"on": false})).unwrap(); // Snapping to a vertex near the pointer. s.run("map.goToXY", &json!({"lon": 20, "lat": 0, "scale": 50_000})).unwrap(); let off = s.run("edit.snap", &json!({"lon": 20.0001, "lat": 0.0001})).unwrap(); assert_eq!(off["snapped"], false, "snapping is off"); s.run("edit.snapping", &json!({"on": true, "tolerancePx": 20})).unwrap(); let on = s.run("edit.snap", &json!({"lon": 20.0001, "lat": 0.0001})).unwrap(); assert_eq!(on["snapped"], true); assert!((on["lon"].as_f64().unwrap() - 20.0).abs() < 1e-9 && on["lat"].as_f64().unwrap().abs() < 1e-9, "{on}"); // A dragged vertex doesn't snap to the feature it belongs to. s.run("map.goToXY", &json!({"lon": 0.5, "lat": 0.5, "scale": 50_000})).unwrap(); let paths = s.map().layer_by_name("Paths").unwrap().id; let near = rustgis_geom::Coord::new(0.5001, 0.5001); let own = crate::cmd::design::snap_except(&mut s, near, None).map(|(c, _)| c); assert!(own.is_some_and(|c| (c.x - 0.5).abs() < 1e-9 && (c.y - 0.5).abs() < 1e-9), "the line's own vertex: {own:?}"); assert_eq!(crate::cmd::design::snap_except(&mut s, near, Some((paths, 0))), None); // Topology: overlapping countries? Lakes inside countries? Dangling paths. s.run("topology.rules", &json!({"add": {"rule": "mustNotHaveDangles", "layer": "Paths"}})).unwrap(); s.run("topology.rules", &json!({"add": {"rule": "mustBeCoveredBy", "layer": "Cities", "by": "Countries"}})).unwrap(); assert!(s.run("topology.rules", &json!({"add": {"rule": "mustNotOverlap", "layer": "Paths"}})).is_err(), "overlap is for polygons"); let v = s.run("topology.validate", &Value::Null).unwrap(); assert!(v["errors"].as_u64().unwrap() > 0); assert_eq!(s.topology_errors.len() as u64, v["errors"].as_u64().unwrap()); // Relationships: cities to their country by ISO code. s.run("data.relate", &json!({"name": "Country of city", "origin": "Countries", "originKey": "name", "destination": "Cities", "destinationKey": "adm0name"})).unwrap(); assert!( s.run( "data.relate", &json!({"name": "x", "origin": "Countries", "originKey": "nope", "destination": "Cities", "destinationKey": "adm0name"}) ) .is_err() ); s.run("select.byAttributes", &json!({"layer": "Countries", "where": "name = 'France'"})).unwrap(); let rel = s.run("data.related", &Value::Null).unwrap(); assert!(rel["related"].as_u64().unwrap() >= 1, "{rel}"); // Elevation from the raster surface goes in z. s.run("edit.elevation", &json!({"mode": "constant", "z": 125})).unwrap(); s.run("layer.template", &json!({"template": "points", "name": "Spots"})).unwrap(); s.run("edit.addFeature", &json!({"layer": "Spots", "geometry": {"type": "Point", "coordinates": [0, 0]}})).unwrap(); let l = s.map().layer_by_name("Spots").unwrap(); assert_eq!(l.dataset.value(0, l.dataset.field_index("z").unwrap()).as_f64(), Some(125.0)); assert!(s.run("edit.elevation", &json!({"mode": "surface", "surface": "Cities"})).is_err(), "not a raster"); let tools = s.run("edit.tools", &Value::Null).unwrap(); assert!(tools.as_array().unwrap().len() >= 9); } /// A 40×40 lon/lat DEM near (10°E, 45°N): a hill in the middle, with a wall to its east. fn dem_b64() -> String { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: 10.0, origin_y: 45.04, pixel_width: 0.001, pixel_height: -0.001 }; let z: Vec = (0..1600) .map(|i| { let (c, r) = ((i % 40) as f32 - 20.0, (i / 40) as f32 - 20.0); let hill = 100.0 * (-(c * c + r * r) / 60.0).exp(); if i % 40 == 30 { 400.0 } else { 200.0 + hill } }) .collect(); let r = Raster::new("dem", 40, 40, vec![z], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()) } #[test] fn analysis_workflows_models_scripts_and_raster_functions() { let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); // Raster functions and a chain. for (func, extra) in [ ("slope", json!({})), ("aspect", json!({})), ("hillshade", json!({"azimuth": 300})), ("threshold", json!({"value": 250})), ("bandMath", json!({"expression": "b1 * 2 - 100"})), ] { let mut p = json!({"layer": dem, "function": func}); p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); s.run("raster.function", &p).unwrap_or_else(|e| panic!("{func}: {e}")); } assert!(s.run("raster.function", &json!({"layer": dem, "function": "warp"})).is_err()); assert!(s.run("raster.function", &json!({"layer": dem, "function": "bandMath", "expression": "b2"})).is_err()); // A remap's `keep`, read off the command's thread, is still said when it can't be read (#1600). let remap = json!({"layer": dem, "function": "remap", "ranges": [[0, 300, 1]], "keep": "auto"}); s.run("raster.function", &remap).unwrap(); assert!(s.notices.iter().any(|n| n.text.contains("keep = \"auto\" (read as false)")), "{:?}", s.notices.last()); let r = s .run( "raster.functionChain", &json!({"layer": dem, "steps": [{"function": "slope"}, {"function": "threshold", "value": 10}], "name": "Steep"}), ) .unwrap(); let steep = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(steep.name, "Steep"); let data = steep.raster.as_ref().unwrap().data.as_ref().unwrap(); assert!(data.bands[0].stats.max == 1.0 && data.bands[0].stats.min == 0.0); // Rasters made by functions save with the project. let text = serde_json::to_string(&s.project).unwrap(); let back = crate::Project::from_json(&text).unwrap(); assert!(back.map().layer_by_name("Steep").unwrap().raster.as_ref().unwrap().data.is_some()); // Suitability: high and flat. let slope_name = format!("{dem}_slope"); let r = s .run( "analysis.suitability", &json!({"criteria": [ {"layer": dem, "ranges": [[0, 250, 1], [250, 1000, 10]], "weight": 2}, {"layer": slope_name, "ranges": [[0, 5, 10], [5, 90, 1]], "weight": 1}]}), ) .unwrap(); assert!(r["layer"].as_u64().is_some()); // Viewshed and line of sight: the wall at column 30 hides what's beyond it. // On the flat northern edge: everything west of the wall is in view. let v = s.run("analysis.viewshed", &json!({"layer": dem, "lon": 10.0055, "lat": 45.0395, "height": 2})).unwrap(); assert!(v["visibleCells"].as_u64().unwrap() > 100, "{v}"); let los = s.run("analysis.lineOfSight", &json!({"layer": dem, "from": [10.0205, 45.0395], "to": [10.0355, 45.0395]})).unwrap(); assert_eq!(los["visible"], false); let los = s.run("analysis.lineOfSight", &json!({"layer": dem, "from": [10.0205, 45.0395], "to": [10.0055, 45.0395]})).unwrap(); assert_eq!(los["visible"], true); // IDW of city populations, with an environment extent and cell size. s.run("gp.env", &json!({"extent": [-10, 35, 30, 60], "cellSize": 0.5})).unwrap(); let r = s.run("analysis.interpolate", &json!({"layer": "Cities", "field": "pop_max"})).unwrap(); let idw = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((idw.width, idw.height), (80, 50)); // The environment extent limits tools to the features meeting it. s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 10, "output": "Europe buffers"}})).unwrap(); let europe = s.map().layer_by_name("Europe buffers").unwrap().dataset.features.len(); let all = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); assert!(europe > 10 && europe < all / 2, "{europe} of {all}"); let env = s.run("gp.env", &json!({"extent": null, "cellSize": null})).unwrap(); // This computer's memory limits are said, never below the browser's fixed ones (#1291 step 2). let limit = |k: &str| env["limits"][k].as_u64().unwrap_or(0) as usize; assert!(limit("rasterSamples") >= rustgis_data::raster::SAMPLES_FLOOR, "{env}"); assert!(limit("outputPixels") >= rustgis_analysis::rasterops::PIXELS_FLOOR, "{env}"); assert!(limit("pagedFeatures") >= rustgis_data::ogc::PAGED_FEATURES_FLOOR, "{env}"); assert!(limit("densityCells") >= rustgis_analysis::DENSITY_CELLS_FLOOR, "{env}"); assert!(limit("bufferBytes") >= 512 << 20 && limit("threadScratchBytes") >= 256 << 20, "{env}"); assert!(limit("projectBytes") as u64 == crate::project::max_bytes() && limit("projectBytes") >= 512 << 20, "{env}"); // Network route along the rivers: two points on the same river. let rivers = s.map().layer_by_name("Rivers").unwrap(); let (a, b2) = match rivers.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).find_map(|g| match g { rustgis_data::Geometry::Line(p) if p[0].len() > 10 => Some((p[0][0], p[0][p[0].len() - 1])), _ => None, }) { Some(x) => x, None => panic!("no river"), }; let r = s.run("analysis.route", &json!({"layer": "Rivers", "from": [a.x, a.y], "to": [b2.x, b2.y]})).unwrap(); assert!(r["lengthKm"].as_f64().unwrap() > 10.0, "{r}"); // Neighbourhood, simulation, profile, exploration, similarity, conversion. let n = s .run("analysis.neighborhood", &json!({"lon": 2.35, "lat": 48.85, "radius": 300000, "layers": ["Cities"], "select": true})) .unwrap(); assert!(n["layers"][0]["count"].as_u64().unwrap() >= 2); // A center that's no place is refused, not taken as a pole (#1033). assert!(s.run("analysis.neighborhood", &json!({"lon": 2.35, "lat": 1000, "radius": 300000})).is_err()); // Lines near a pole with more vertices than one search measures: those past its steps are // counted at the least distance they could be, and it says how many (#1033); a search that // measures every one says nothing. let mut ds = rustgis_data::Dataset::new("Spirals", rustgis_data::GeometryType::Polyline, vec![]); for _ in 0..300 { let line = (0..=348).map(|i| rustgis_data::Coord::new(if i % 2 == 0 { -179.5 } else { 179.5 }, 89.95)).collect(); ds.push(Some(rustgis_data::Geometry::Line(vec![line])), vec![]); } s.map_mut().add_dataset(ds); let mark = s.notice_mark(); let n = s.run("analysis.neighborhood", &json!({"lon": 0, "lat": 89.9, "radius": 10000, "layers": ["Spirals"]})).unwrap(); assert_eq!(n["layers"][0]["count"], 300); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert!(said.iter().any(|t| t.starts_with("299 feature(s) near a pole (Spirals 299)") && t.contains("beyond the radius")), "{said:?}"); let mark = s.notice_mark(); s.run("analysis.neighborhood", &json!({"lon": 0, "lat": 89.9, "radius": 10000, "layers": ["Cities"]})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("near a pole")), "{:?}", s.notices_since(mark)); s.run("select.byAttributes", &json!({"layer": "Countries", "where": "name = 'France'"})).unwrap(); let sim = s.run("analysis.simulate", &json!({"layer": "Countries", "count": 20, "seed": 3})).unwrap(); assert_eq!(sim["points"], 20); s.run("select.clear", &Value::Null).unwrap(); let prof = s.run("data.profile", &json!({"layer": "Countries"})).unwrap(); assert!(prof["fields"].as_array().unwrap().iter().any(|f| f["field"] == "pop_est" && f["min"].as_f64().is_some())); let ex = s.run("data.explore", &json!({"layer": "Countries", "fields": ["pop_est", "gdp_md"]})).unwrap(); assert!(ex["correlation"][0][1].as_f64().unwrap() > 0.3, "population and GDP correlate"); let fr = s.map().layer_by_name("Countries").unwrap().dataset.features.iter().position(|_| true).unwrap(); let sm = s.run("data.similar", &json!({"layer": "Countries", "row": fr, "fields": ["pop_est", "gdp_md"], "count": 3})).unwrap(); assert_eq!(sm["similar"].as_array().unwrap().len(), 3); for fmt in ["kml", "gpx", "wkt", "geojson", "csv"] { let t = s.run("data.convert", &json!({"layer": "Cities", "format": fmt})).unwrap(); assert!(t["text"].as_str().unwrap().len() > 1000, "{fmt}"); } // Models chain tools; scripts run commands. s.run( "model.new", &json!({"name": "Buffer then dissolve", "steps": [ {"tool": "buffer", "params": {"input": "Cities", "distance": 50}}, {"tool": "dissolve", "params": {"input": "%1"}}]}), ) .unwrap(); let r = s.run("model.run", &json!({"name": "Buffer then dissolve"})).unwrap(); assert_eq!(r["ran"].as_array().unwrap().len(), 2); assert!(s.run("model.new", &json!({"steps": [{"tool": "nope", "params": {}}]})).is_err()); // A model carried in a file can't touch files. s.project.models.push(crate::project::Model { name: "From a file".into(), steps: vec![crate::project::ModelStep { tool: "buffer".into(), params: json!({"input": "Cities", "distance": 5, "output": "/tmp/x.shp"}), }], ..Default::default() }); let e = s.run("model.run", &json!({"name": "From a file"})).unwrap_err(); assert!(e.contains("from a file"), "{e}"); let m = s.run("model.new", &json!({"name": "Replay", "fromHistory": true})).unwrap(); assert!(m["steps"].as_u64().unwrap() >= 2); let dissolve = s.project.models.last().unwrap().steps.iter().find(|st| st.tool == "dissolve").unwrap(); assert!(dissolve.params["input"].as_str().unwrap().starts_with('%'), "history inputs wire to earlier steps"); let r = s.run("script.run", &json!({"script": "# zoom then select\nmap.goToXY {\"lon\": 2, \"lat\": 48, \"scale\": 5000000}\nselect.byAttributes {\"layer\": \"Cities\", \"where\": \"pop_max > 1000000\"}"})).unwrap(); assert_eq!(r["ran"].as_array().unwrap().len(), 2); assert!(s.run("script.run", &json!({"script": "no.such {}"})).is_err()); model_graphs(&mut s); let g = s.run("gp.gallery", &json!({"kind": "raster"})).unwrap(); assert!(g.as_array().unwrap().iter().any(|t| t["id"] == "raster.function")); } /// A 100 × 80 scan whose value is `ramp(col, row)` (so a bilinear warp reproduces it exactly), with /// a bright pixel at (50, 40), added as a layer with a placeholder position. fn scan_layer(s: &mut Session, name: &str) -> (fn(f64, f64) -> f32, (f64, f64)) { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; fn ramp(c: f64, r: f64) -> f32 { (c * 2.0 + r * 0.5) as f32 } let (w, h) = (100u32, 80u32); let mut z: Vec = (0..w * h).map(|i| ramp(f64::from(i % w) + 0.5, f64::from(i / w) + 0.5)).collect(); z[(40 * w + 50) as usize] = 5000.0; let gt = GeoTransform { origin_x: 0.0, origin_y: 80.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new(name, w, h, vec![z], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); (ramp, (50.5, 40.5)) } /// The control points of a known map from the scan's corners and middle: `f` takes a pixel position /// to map coordinates. fn points_of(f: impl Fn(f64, f64) -> (f64, f64)) -> Value { let pts: Vec = [(0.0, 0.0), (100.0, 0.0), (0.0, 80.0), (100.0, 80.0), (50.0, 40.0), (25.0, 60.0)] .iter() .map(|&(c, r)| { let (x, y) = f(c, r); json!([c, r, x, y]) }) .collect(); Value::Array(pts) } fn out_raster(s: &Session, v: &Value) -> std::sync::Arc { let id = v["layer"].as_u64().unwrap() as u32; s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap() } /// Warping through a known sheared affine puts every pixel where the matrix says: a bilinear /// warp of a ramp reproduces the ramp at the pixel each output cell comes from, and the bright /// pixel lands within half a cell of its place. #[test] fn a_warp_through_a_known_affine_puts_pixels_where_the_matrix_says() { let mut s = world(); let (ramp, bright) = scan_layer(&mut s, "scan"); // x = 500000 + 10 col + 2 row, y = 4000000 - 10 row + 1 col, in UTM 13N. let map = |c: f64, r: f64| (500_000.0 + 10.0 * c + 2.0 * r, 4_000_000.0 - 10.0 * r + c); let v = s .run( "raster.georeference", &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "name": "warped", "resampling": "bilinear"}), ) .unwrap(); assert_eq!(v["crs"], "EPSG:32613", "{v}"); let out = out_raster(&s, &v); assert!(s.map().layer_by_name("scan").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().width == 100, "the scan is kept"); // The cell is the root of the pixel's area: |10 × -10 - 2 × 1| = 102. let cell = 102f64.sqrt(); assert!((out.transform.pixel_width - cell).abs() < 1e-9 && (out.transform.pixel_height + cell).abs() < 1e-9); // The grid covers the footprint, whose corners are the matrix's corners. let corners = [(0.0, 0.0), (100.0, 0.0), (0.0, 80.0), (100.0, 80.0)].map(|(c, r)| map(c, r)); let (min_x, max_x) = corners.iter().fold((f64::MAX, f64::MIN), |(a, b), (x, _)| (a.min(*x), b.max(*x))); let (min_y, max_y) = corners.iter().fold((f64::MAX, f64::MIN), |(a, b), (_, y)| (a.min(*y), b.max(*y))); assert_eq!((out.transform.origin_x, out.transform.origin_y), (min_x, max_y)); assert!(out.transform.origin_x + out.transform.pixel_width * f64::from(out.width) >= max_x); assert!(out.transform.origin_y + out.transform.pixel_height * f64::from(out.height) <= min_y); // The matrix's inverse takes a map position back to the scan. let det = 10.0 * -10.0 - 2.0 * 1.0; let back = |x: f64, y: f64| { let (dx, dy) = (x - 500_000.0, y - 4_000_000.0); ((-10.0 * dx - 2.0 * dy) / det, (-dx + 10.0 * dy) / det) }; let (mut checked, mut empty) = (0, 0); for row in (0..i64::from(out.height)).step_by(7) { for col in (0..i64::from(out.width)).step_by(5) { let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); let (sc, sr) = back(c.x, c.y); match out.value(0, col, row) { // Away from the scan's edge and its bright pixel the value is the ramp's there. Some(v) if (1.5..98.5).contains(&sc) && (1.5..78.5).contains(&sr) && (sc - 50.5).abs() > 2.0 && (sr - 40.5).abs() > 2.0 => { assert!((v - ramp(sc, sr)).abs() < 1e-3, "cell {col},{row}: {v} against {}", ramp(sc, sr)); checked += 1; } Some(_) => {} None => { assert!(!(0.0..100.0).contains(&sc) || !(0.0..80.0).contains(&sr), "cell {col},{row} comes from inside the scan"); empty += 1; } } } } assert!(checked > 100 && empty > 0, "{checked} checked, {empty} outside the scan"); // The bright pixel: its intensity-weighted position is within half a cell of the matrix's. let (mut sum, mut sx, mut sy) = (0.0f64, 0.0f64, 0.0f64); for row in 0..i64::from(out.height) { for col in 0..i64::from(out.width) { if let Some(v) = out.value(0, col, row).filter(|v| *v > 1000.0) { let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); sum += f64::from(v); sx += f64::from(v) * c.x; sy += f64::from(v) * c.y; } } } let (ex, ey) = map(bright.0, bright.1); assert!( sum > 0.0 && (sx / sum - ex).abs() <= cell / 2.0 && (sy / sum - ey).abs() <= cell / 2.0, "({}, {}) against ({ex}, {ey})", sx / sum, sy / sum ); } /// A 30° rotation, nearest sampling, replacing the layer's raster: the cells of the bright pixel /// are centred within half a cell of its place, and the grid's corners outside the turned image /// are no data. #[test] fn a_warp_through_a_known_rotation_puts_pixels_where_the_turn_says() { let mut s = world(); let (_, bright) = scan_layer(&mut s, "scan"); let (sin, cos) = 30f64.to_radians().sin_cos(); // Ten metres a pixel, turned 30° about the origin (rows still run down the picture). let map = |c: f64, r: f64| (500_000.0 + 10.0 * (c * cos + r * sin), 4_000_000.0 + 10.0 * (c * sin - r * cos)); let v = s .run( "raster.georeference", &json!({"layer": "scan", "transform": "similarity", "pointsCrs": 32613, "crs": "EPSG:32613", "points": points_of(map), "resampling": "nearest", "cellSize": 2.5}), ) .unwrap(); let out = out_raster(&s, &v); assert_eq!( s.map().layer_by_name("scan").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().width, out.width, "replaced in place" ); assert!(out.width > 400, "the turned image needs a wider grid than its 1000 m / 2.5 m: {}", out.width); // Nearest keeps the values: the bright pixel's cells are 5000, a square 10 m across turned 30°. let (mut n, mut sx, mut sy) = (0.0f64, 0.0f64, 0.0f64); for row in 0..i64::from(out.height) { for col in 0..i64::from(out.width) { if out.value(0, col, row) == Some(5000.0) { let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); n += 1.0; sx += c.x; sy += c.y; } } } let (ex, ey) = map(bright.0, bright.1); let cell = out.transform.pixel_width; // About 100 / 6.25 = 16 cells. assert!((10.0..=22.0).contains(&n), "{n} cells"); assert!((sx / n - ex).abs() <= cell / 2.0 && (sy / n - ey).abs() <= cell / 2.0, "({}, {}) against ({ex}, {ey})", sx / n, sy / n); // The turned rectangle leaves the box's corners empty. assert_eq!(out.value(0, 0, 0), None); assert_eq!(out.value(0, i64::from(out.width) - 1, i64::from(out.height) - 1), None); // At its default the cell is the pixel's 10 m. let d = s .run( "raster.georeference", &json!({"layer": "scan", "transform": "similarity", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "name": "d"}), ) .unwrap(); assert!((out_raster(&s, &d).transform.pixel_width - 10.0).abs() < 1e-6); } /// Control points in longitude/latitude, warped into UTM: every cell holds the scan's value at /// the pixel the projection takes the cell's centre back to. #[test] fn a_warp_reprojects_from_the_points_crs() { use rustgis_data::raster::Crs; let mut s = world(); let (ramp, bright) = scan_layer(&mut s, "scan"); // A north-up block at 40°N, 105°W, a thousandth of a degree a pixel. let map = |c: f64, r: f64| (-105.0 + c * 0.001, 40.0 - r * 0.001); let v = s .run( "raster.georeference", &json!({"layer": "scan", "transform": "affine", "crs": 32613, "pointsCrs": "EPSG:4326", "points": points_of(map), "name": "utm"}), ) .unwrap(); let out = out_raster(&s, &v); assert_eq!(out.crs.epsg(), 32613); let t = Crs::from_epsg(32613).unwrap().transformer().unwrap(); let mut checked = 0; for row in (0..i64::from(out.height)).step_by(3) { for col in (0..i64::from(out.width)).step_by(3) { let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); let ll = t.to_lonlat(c).unwrap(); let (sc, sr) = ((ll.x + 105.0) / 0.001, (40.0 - ll.y) / 0.001); if (2.0..98.0).contains(&sc) && (2.0..78.0).contains(&sr) && (sc - 50.5).abs() > 2.5 && (sr - 40.5).abs() > 2.5 { let got = out.value(0, col, row).unwrap(); assert!((got - ramp(sc, sr)).abs() < 0.05, "cell {col},{row}: {got} against {}", ramp(sc, sr)); checked += 1; } } } assert!(checked > 100, "{checked}"); // The bright pixel's own UTM position is within a cell of a bright cell. let (lon, lat) = map(bright.0, bright.1); let at = t.from_lonlat(rustgis_geom::Coord::new(lon, lat)).unwrap(); let (col, row) = out.transform.to_pixel(at); let (col, row) = (col.floor() as i64, row.floor() as i64); let near = (-1..=1) .flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))) .filter_map(|(dx, dy)| out.value(0, col + dx, row + dy)) .fold(f32::MIN, f32::max); assert!(near > 500.0, "{near}"); } /// A 48-bit colour scan (UInt16 RGB) warps with alpha the renderer reads as opaque (it takes /// alpha as 0–255 in every type), and the COG keeps the 16-bit type. #[test] fn a_sixteen_bit_colour_warp_is_opaque_to_the_renderer() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let mut s = world(); let n = 60 * 40; let bands = vec![vec![60_000.0; n], vec![30_000.0; n], vec![1000.0; n]]; let gt = GeoTransform { origin_x: 0.0, origin_y: 40.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new("deep", 60, 40, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt16").unwrap(); let bytes = rustgis_data::raster::geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "deep", rustgis_map::RasterLayer::from_bytes(r, "deep.tif", bytes)).unwrap(); let (sin, cos) = 20f64.to_radians().sin_cos(); let map = |c: f64, r: f64| (500_000.0 + 10.0 * (c * cos + r * sin), 4_000_000.0 + 10.0 * (c * sin - r * cos)); let pts: Vec = [(0.0, 0.0), (60.0, 0.0), (0.0, 40.0), (60.0, 40.0)].iter().map(|&(c, r)| json!([c, r, map(c, r).0, map(c, r).1])).collect(); let path = std::env::temp_dir().join(format!("rustgis-deep-{}.tif", std::process::id())); let v = s .run("raster.georeference", &json!({"layer": "deep", "transform": "similarity", "pointsCrs": 32613, "points": pts, "name": "w", "path": path.display().to_string()})) .unwrap(); let out = out_raster(&s, &v); assert_eq!(out.color, ColorHint::Rgb { alpha: true }); let (mid_c, mid_r) = (i64::from(out.width) / 2, i64::from(out.height) / 2); // The renderer clamps alpha to 0–255: inside is 255, the collar 0. let alpha = |c, r| out.value(3, c, r).map(|v| v.clamp(0.0, 255.0) as u8); assert_eq!((alpha(mid_c, mid_r), alpha(0, 0)), (Some(255), Some(0))); let back = rustgis_data::raster::read_path(&path).unwrap(); assert_eq!((back.data_type.as_str(), back.band_count()), ("UInt16", 4)); assert_eq!(back.value(0, mid_c, mid_r), Some(60_000.0)); std::fs::remove_file(&path).ok(); } /// A scan that would span the globe in degrees is refused, and a long thin grid can't slip past /// the size limit through its row buffers. #[test] fn a_warp_refuses_a_globe_in_degrees_and_bounds_its_memory() { use rustgis_analysis::georef::{self, Gcp, Kind}; use rustgis_analysis::warp::{self, Options, Resampling}; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let (w, h) = (40u32, 40u32); let gt = GeoTransform { origin_x: 0.0, origin_y: 40.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new("s", w, h, vec![vec![1.0; 1600]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); // A square of 2000 km centred on the pole, in polar stereographic (EPSG:3413). let gcps = |f: &dyn Fn(f64, f64) -> (f64, f64)| -> Vec { [(0.0, 0.0), (40.0, 0.0), (0.0, 40.0), (40.0, 40.0)] .iter() .map(|&(c, r)| { let (x, y) = f(c, r); Gcp { col: c, row: r, x, y, on: true } }) .collect() }; let fit = georef::fit(Kind::Affine, &gcps(&|c, r| (-1_000_000.0 + 50_000.0 * c, 1_000_000.0 - 50_000.0 * r))).unwrap(); let o = |crs: Crs, points: Crs, cell| Options { crs, points_crs: points, resampling: Resampling::Nearest, cell, name: "o".into() }; let polar = Crs::from_epsg(3413).unwrap(); // In degrees the scan spans every longitude: refused, with a hint. let e = warp::warp(&r, &fit, &o(Crs::LonLat, polar, Some(0.05))).unwrap_err(); assert!(e.contains("projected crs"), "{e}"); // In Web Mercator it is placed (to its latitude limit), with the pole inside it. assert!(warp::warp(&r, &fit, &o(Crs::WebMercator, polar, Some(100_000.0))).is_ok()); // A strip a billion metres long and a metre tall, from hostile points: past the most samples // any computer is allowed (rustgis_data::raster::SAMPLES_CEILING), its row buffers counted. let strip = georef::fit(Kind::Affine, &gcps(&|c, r| (500_000.0 + c * 2.5e7, 4_000_000.0 - r * 0.025))).unwrap(); let utm13 = Crs::from_epsg(32613).unwrap(); let e = warp::warp(&r, &strip, &o(utm13, utm13, Some(1.0))).unwrap_err(); assert!(e.contains("cellSize"), "{e}"); } /// A warp written as a COG becomes the layer's file; the options are checked. #[test] fn a_warp_can_write_a_cog_and_refuses_what_it_cannot_place() { let mut s = world(); scan_layer(&mut s, "scan"); let map = |c: f64, r: f64| (500_000.0 + 10.0 * c, 4_000_000.0 - 10.0 * r); let dir = std::env::temp_dir().join(format!("rustgis-warp-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("warped.tif"); let v = s .run( "raster.georeference", &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "resampling": "cubic", "cellSize": 5, "name": "w", "path": path.display().to_string()}), ) .unwrap(); assert_eq!(v["cog"]["overviews"], 0, "200 × 160 is one tile: {v}"); assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(200), Some(160)), "half-size cells double the grid"); let back = rustgis_data::raster::read_path(&path).unwrap(); assert_eq!((back.width, back.height, back.crs.epsg()), (200, 160, 32613)); assert_eq!(back.transform.origin_x, 500_000.0); let layer = s.map().layer_by_name("w").unwrap().raster.as_ref().unwrap(); assert_eq!(layer.path.as_deref(), Some(path.display().to_string().as_str()), "the file is the layer's source"); // The same command without a transform is the old north-up fit. s.run("raster.georeference", &json!({"layer": "scan", "extent": [10.0, 45.0, 10.1, 45.08]})).unwrap(); let ok = json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map)}); s.run("raster.georeference", &ok).unwrap(); for (k, bad) in [ ("transform", json!("warp")), ("transform", json!(7)), ("crs", json!("EPSG:99999999")), ("crs", json!(-1)), ("crs", json!(1e30)), ("pointsCrs", json!([])), ("resampling", json!("lanczos")), ("cellSize", json!(0)), ("cellSize", json!(-3)), ("cellSize", json!(1e-9)), ("cellSize", json!("x")), ("path", json!("warped.png")), ("path", json!(5)), ("points", json!([[0, 0, 1, 1]])), ] { let mut p = ok.clone(); p[k] = bad.clone(); assert!(s.run("raster.georeference", &p).is_err(), "{k} = {bad}"); } // A fit that collapses the image onto a line has no grid. assert!( s.run( "raster.georeference", &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(|c, _| (c, c))}) ) .is_err() ); std::fs::remove_dir_all(&dir).ok(); } /// A scan opened from a PNG and warped onto its points has coordinates for real: the cell is the /// fit's pixel on the ground, not the stand-in size it was laid over the view at; the pixels land /// where the fit says; it can be exported; and the project reopens it where the warp put it, not /// laid over the view again. #[test] fn a_scan_warped_onto_its_points_is_placed_for_real() { let dir = std::env::temp_dir().join(format!("rustgis-scan-warp-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("plate.png"); image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([x as u8, y as u8, 9]))) .save_with_format(&png, image::ImageFormat::Png) .unwrap(); let mut s = world(); let r = s.run("layer.addData", &json!({"path": png.display().to_string()})).unwrap(); assert_eq!(r["georeferenced"], false, "{r}"); let laid = s.map().layer_by_name("plate").unwrap().raster.clone().unwrap(); assert!(laid.placement.is_some()); let stand_in = laid.data.as_ref().unwrap().transform.pixel_width; // 7.5 m a pixel in UTM 13N, from the scan's corners and two points inside it; the output's // system is the points'. let map = |c: f64, r: f64| (500_000.0 + 7.5 * c, 4_000_000.0 - 7.5 * r); let pts: Vec = [(0.0, 0.0), (40.0, 0.0), (0.0, 20.0), (40.0, 20.0), (20.0, 10.0), (10.0, 15.0)] .iter() .map(|&(c, r)| { let (x, y) = map(c, r); json!([c, r, x, y]) }) .collect(); let v = s .run( "raster.georeference", &json!({"layer": "plate", "transform": "affine", "pointsCrs": 32613, "points": pts, "resampling": "nearest"}), ) .unwrap(); assert_eq!(v["crs"], "EPSG:32613", "{v}"); let l = s.map().layer_by_name("plate").unwrap().raster.clone().unwrap(); assert!(l.placement.is_none(), "no longer laid over the view"); let out = l.data.clone().unwrap(); assert!(out.georeferenced && out.crs.epsg() == 32613); assert!( (out.transform.pixel_width - 7.5).abs() < 1e-6 && (out.transform.pixel_height + 7.5).abs() < 1e-6 && (stand_in - 7.5).abs() > 0.5, "{:?}, laid at {stand_in}", out.transform ); // Red is the column and green the row, so a pixel reads back where the fit puts it; the scan // had no alpha or no data, so it gains an opaque alpha band. assert_eq!(out.band_count(), 4); let (x, y) = map(10.5, 5.5); let (col, row) = out.transform.to_pixel(rustgis_geom::Coord { x, y }); let (col, row) = (col.floor() as i64, row.floor() as i64); let px: Vec> = (0..4).map(|b| out.value(b, col, row)).collect(); assert_eq!(px, [Some(10.0), Some(5.0), Some(9.0), Some(255.0)], "cell {col},{row}"); // Exporting is no longer refused. let tif = dir.join("plate.tif"); s.run("share.exportRaster", &json!({"layer": "plate", "path": tif.display().to_string(), "cog": true})).unwrap(); let back = rustgis_data::raster::read_path(&tif).unwrap(); assert!(back.georeferenced && back.crs.epsg() == 32613 && back.transform == out.transform); // The project reopens it where the warp put it. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("plate").unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert!(again.georeferenced && again.transform == out.transform, "{:?} vs {:?}", again.transform, out.transform); std::fs::remove_dir_all(&dir).ok(); } #[test] fn exporting_a_raster_as_a_cog() { let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); let dir = std::env::temp_dir().join(format!("rustgis-cog-export-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (plain, cog) = (dir.join("plain.tif"), dir.join("cog.tif")); let v = s.run("share.exportRaster", &json!({"layer": dem, "path": plain.display().to_string()})).unwrap(); assert!(v.get("cog").is_none(), "an untiled GeoTIFF unless asked"); let v = s.run("share.exportRaster", &json!({"layer": dem, "path": cog.display().to_string(), "cog": true})).unwrap(); assert_eq!(v["cog"]["bigTiff"], false, "{v}"); let (a, b) = (rustgis_data::raster::read_path(&plain).unwrap(), rustgis_data::raster::read_path(&cog).unwrap()); assert_eq!((a.width, a.height, a.transform), (b.width, b.height, b.transform)); assert!(a.bands.iter().zip(&b.bands).all(|(x, y)| x.data == y.data)); // Tiled, not stripped: the tile tags are in the file. let bytes = std::fs::read(&cog).unwrap(); assert!(bytes.windows(2).any(|w| w == [0x42, 0x01]), "TileWidth (322) is in a directory"); assert!(s.run("share.exportRaster", &json!({"layer": dem, "path": cog.display().to_string(), "cog": true, "band": 99})).is_err()); assert!( s.run("share.exportRaster", &json!({"layer": dem, "path": dir.join("no").join("x.tif").display().to_string(), "cog": true})) .is_err() ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn layer_effects_and_imagery_tools() { use crate::render::View; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 48.0, "scale": 20_000_000})).unwrap(); let view = |s: &Session| View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let png = |s: &mut Session| { let v = view(s); crate::render::render(s.map_mut(), &v).unwrap().pixmap }; // Every effect renders. s.run("layer.blend", &json!({"layer": "Countries", "mode": "multiply"})).unwrap(); assert!(s.run("layer.blend", &json!({"layer": "Countries", "mode": "dodge"})).is_err()); s.run("layer.mask", &json!({"layer": "Cities", "mask": "Countries"})).unwrap(); assert!(s.run("layer.mask", &json!({"layer": "Countries", "mask": "Countries"})).is_err()); assert!(s.run("layer.mask", &json!({"layer": "Countries", "mask": "Cities"})).is_err(), "masks are polygons"); s.run("layer.swipe", &json!({"layer": "Countries", "position": 0.5})).unwrap(); let swiped = png(&mut s); // Right of the divider the countries are gone: only water and basemap land remain. s.run("layer.swipe", &json!({"layer": "Countries", "off": true})).unwrap(); let full = png(&mut s); assert_ne!(swiped.data(), full.data()); s.run("layer.displayFilters", &json!({"layer": "Cities", "filters": [{"name": "Big at small scales", "minScale": 1e12, "maxScale": 5_000_000, "where": "pop_max > 5000000"}]})).unwrap(); assert!(s.run("layer.displayFilters", &json!({"layer": "Cities", "filters": [{"where": "(("}]})).is_err()); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "hexagon", "size": 30})).unwrap(); assert!(s.run("layer.aggregation", &json!({"layer": "Countries", "shape": "hexagon"})).is_err()); png(&mut s); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "square"})).unwrap(); png(&mut s); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "field", "field": "pop_est", "factor": 0.002})).unwrap(); s.run("layer.faces", &json!({"layer": "Countries", "culling": false, "lighting": true})).unwrap(); s.run("layer.lightShadow", &json!({"layer": "Countries", "shadows": true})).unwrap(); let extruded = png(&mut s); assert_ne!(extruded.data(), full.data()); assert!(s.run("layer.faces", &json!({"layer": "Lakes", "culling": true})).is_err(), "no extrusion yet"); s.run("raster.rotation", &json!({"layer": dem, "angle": 30})).unwrap(); s.run("map.goToXY", &json!({"lon": 10.02, "lat": 45.02, "scale": 50_000})).unwrap(); png(&mut s); assert!(s.run("raster.rotation", &json!({"layer": "Countries", "angle": 30})).is_err()); s.run("raster.rotation", &json!({"layer": dem, "angle": 0})).unwrap(); // Imagery. let r = s.run("raster.georeference", &json!({"layer": dem, "points": [[0, 0, 20.0, 50.0], [40, 40, 20.4, 49.6]]})).unwrap(); let e = r["extent"].as_array().unwrap(); assert!((e[0].as_f64().unwrap() - 20.0).abs() < 1e-6 && (e[3].as_f64().unwrap() - 50.0).abs() < 1e-6, "{r}"); assert!( s.run("raster.georeference", &json!({"layer": dem, "points": [[0, 0, 20.0, 50.0], [40, 40, 19.0, 51.0]]})).is_err(), "north up only" ); s.run("raster.georeference", &json!({"layer": dem, "extent": [10.0, 45.0, 10.04, 45.04]})).unwrap(); // Control point fits: a sheared grid is affine, a point switched off still gets a residual, // and comparing the transforms recommends the simplest that predicts the points left out. let sheared: Vec = (0..16) .map(|i| { let (c, r) = (f64::from(i % 4) * 100.0, f64::from(i / 4) * 100.0); json!([c, r, 20.0 + c * 0.001 + r * 0.0002, 50.0 - r * 0.001]) }) .collect(); let mut pts = sheared.clone(); pts[5] = json!([100, 100, 20.5, 49.0, 0]); let fit = s.run("raster.gcpFit", &json!({"points": pts, "transform": "affine"})).unwrap(); assert_eq!(fit["used"], 15); assert!(fit["rmse"]["map"].as_f64().unwrap() < 1e-9, "{fit}"); assert!(fit["residuals"][5]["map"][0].as_f64().unwrap() < -0.3, "the switched-off point misses"); let g: Vec = fit["affine"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert!((g[1] - 0.001).abs() < 1e-12 && (g[2] - 0.0002).abs() < 1e-12 && (g[5] + 0.001).abs() < 1e-12, "{g:?}"); let cmp = s.run("raster.gcpFit", &json!({"points": sheared, "compare": true})).unwrap(); assert_eq!(cmp["recommended"], "affine", "{cmp}"); assert!(s.run("raster.gcpFit", &json!({"points": [[0, 0, 1, 1]]})).is_err()); assert!(s.run("raster.gcpFit", &json!({"points": [[0, 0, 1, 1], [1, 1, 2, 2]], "transform": "magic"})).is_err()); let sp = s.run("raster.spectral", &json!({"layer": dem, "lon": 10.0205, "lat": 45.0195})).unwrap(); assert!(sp["values"][0].as_f64().unwrap() > 290.0); s.run("raster.index", &json!({"layer": dem, "index": "ndvi", "nir": 1, "red": 1})).unwrap(); let r = s.run("raster.classify", &json!({"layer": dem, "classes": 3})).unwrap(); // Forms send numbers as floats; whole ones are accepted. s.run("raster.classify", &json!({"layer": dem, "classes": 3.0, "name": "float_classes"})).unwrap(); assert!(s.run("raster.classify", &json!({"layer": dem, "classes": 2.5})).is_err()); assert!(r["layer"].as_u64().is_some()); s.run("raster.function", &json!({"layer": dem, "function": "bandMath", "expression": "b1 + 10", "name": "dem_later"})).unwrap(); let ch = s.run("raster.change", &json!({"before": dem, "after": "dem_later", "method": "difference"})).unwrap(); let ch_layer = s.map().layer(ch["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert!((ch_layer.bands[0].stats.mean - 10.0).abs() < 0.01); s.run("raster.process", &json!({"layer": dem, "operation": "clip", "extent": [10.005, 45.005, 10.02, 45.02]})).unwrap(); let r = s.run("raster.process", &json!({"layer": dem, "operation": "resample", "factor": 4})).unwrap(); let small = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((small.width, small.height), (10, 10)); let r = s .run( "raster.editPixels", &json!({"layer": dem, "polygon": [[10.0, 45.0], [10.01, 45.0], [10.01, 45.01], [10.0, 45.01]], "value": 0}), ) .unwrap(); assert_eq!(r["pixels"], 100); // Supervised classification from two training squares. s.run("layer.template", &json!({"template": "polygons", "name": "Training"})).unwrap(); for (name, x) in [("low", 10.002), ("high", 10.018)] { s.run("edit.addFeature", &json!({"layer": "Training", "geometry": {"type": "Polygon", "coordinates": [[[x, 45.018], [x + 0.004, 45.018], [x + 0.004, 45.022], [x, 45.022], [x, 45.018]]]}, "attributes": {"name": name}})).unwrap(); } let r = s.run("raster.classifySupervised", &json!({"layer": dem, "training": "Training", "classField": "name"})).unwrap(); assert_eq!(r["classes"].as_array().unwrap().len(), 2, "{r}"); // Mensuration. let m = s.run("raster.measure", &json!({"layer": dem, "kind": "height", "points": [[10.0015, 45.0385], [10.0205, 45.0195]]})).unwrap(); assert!(m["metres"].as_f64().unwrap() > 50.0, "{m}"); let volume = json!({"layer": dem, "kind": "volume", "points": [[10.015, 45.015], [10.025, 45.015], [10.025, 45.025], [10.015, 45.025]]}); let on_earth = s.run("raster.measure", &volume).unwrap()["cubicMetresAbove"].as_f64().unwrap(); s.run("raster.measure", &json!({"kind": "area", "points": [[0, 0], [1, 0], [1, 1]]})).unwrap(); assert_eq!(s.run("raster.measurements", &Value::Null).unwrap().as_array().unwrap().len(), 3); // On Mars the degree cells of this raster are Mars' size: the same heights over a smaller // ground (#223). s.run("map.body", &json!({"name": "mars", "basemap": false})).unwrap(); let on_mars = s.run("raster.measure", &volume).unwrap()["cubicMetresAbove"].as_f64().unwrap(); let k = rustgis_geom::Body::get("mars").ratio(); assert!(on_earth > 0.0 && (on_mars / on_earth - k * k).abs() < 1e-9, "{on_earth} {on_mars}"); s.run("map.body", &json!({"name": "earth"})).unwrap(); // Workspaces: an orthomosaic of two rasters, a DSM mosaic and its mesh. s.run("imagery.workspace", &json!({"kind": "ortho", "name": "Valley", "images": [dem, "dem_later"]})).unwrap(); s.run("imagery.workspaceOptions", &json!({"name": "Valley", "cellSize": 0.002})).unwrap(); let r = s.run("imagery.mosaic", &json!({"name": "Valley"})).unwrap(); let mo = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((mo.width, mo.height), (20, 20)); // Images of one wavelength give the mosaic it, read back from its GeoTIFF too; images of // two give it none, and that is said. let give = |s: &mut Session, layer: &str, nm: Option| { let id = s.map().layer_by_name(layer).unwrap().id; let data = s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().data.as_mut().unwrap(); std::sync::Arc::make_mut(data).bands[0].wavelength = nm; }; give(&mut s, &dem, Some(842.0)); give(&mut s, "dem_later", Some(842.0)); let r = s.run("imagery.mosaic", &json!({"name": "Valley"})).unwrap(); assert!(r.get("messages").is_none(), "{r}"); let layer = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap(); assert_eq!(layer.data.as_ref().unwrap().bands[0].wavelength, Some(842.0)); let bytes = layer.embedded.clone().expect("the mosaic's GeoTIFF"); let back = rustgis_data::raster::geotiff::read(std::io::Cursor::new(bytes.as_slice()), "back").unwrap(); assert_eq!(back.bands[0].wavelength, Some(842.0)); // A rounding apart is one wavelength, as the readers take it. give(&mut s, "dem_later", Some(842.000_000_1)); let r = s.run("imagery.mosaic", &json!({"name": "Valley"})).unwrap(); assert!(r.get("messages").is_none(), "{r}"); give(&mut s, "dem_later", Some(665.0)); let r = s.run("imagery.mosaic", &json!({"name": "Valley"})).unwrap(); assert_eq!( r["messages"], json!(["1 band(s) have no wavelength in the mosaic: the images don't all give the same one (or some give none)"]), "{r}" ); let mo = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!(mo.bands[0].wavelength, None); give(&mut s, &dem, None); give(&mut s, "dem_later", None); s.run("imagery.workspace", &json!({"kind": "reality", "images": ["dem_later"]})).unwrap(); let r = s.run("imagery.mosaic", &json!({"name": "Reality Mapping"})).unwrap(); let dsm = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().name.clone(); let mesh = s.run("imagery.mesh", &json!({"layer": dsm})).unwrap(); assert!(mesh["faces"].as_u64().unwrap() > 100); // Meshes go to .obj files; workspace methods are first or mean, checked on creation too. assert!(s.run("imagery.mesh", &json!({"layer": dsm, "path": "/tmp/dsm.png"})).is_err()); assert!(s.run("imagery.workspace", &json!({"kind": "ortho", "images": [dem], "method": "median"})).is_err()); assert!(s.run("imagery.workspace", &json!({"kind": "ortho", "images": [dem], "cellSize": 20})).is_err()); // A mean mosaic keeps fractional values. s.run("imagery.workspaceOptions", &json!({"name": "Valley", "method": "mean"})).unwrap(); let r = s.run("imagery.mosaic", &json!({"name": "Valley"})).unwrap(); let mo = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!(mo.data_type, "Float32"); assert!(s.run("imagery.workspace", &json!({"kind": "lidar", "images": [dem]})).is_err()); // Effects and workspaces save with the project. let p = crate::Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert!(p.map().layer_by_name("Countries").unwrap().extrusion.is_some() && p.workspaces.len() == 2); } #[test] fn the_globe_has_stars_that_turn_off() { use crate::render::View; let mut s = world(); s.run("map.viewMode", &json!({"mode": "global"})).unwrap(); // The space in the corner, away from the globe. let corner = |s: &mut Session| { let v = View { width: 1200.0, height: 800.0, pixels_per_point: 1.0, camera: s.map().camera, sky: true }; let pm = crate::render::render(s.map_mut(), &v).unwrap().pixmap; let mut px = Vec::new(); for y in 0..150 { for x in 0..150 { px.push(pm.pixel(x, y).map(|c| (c.red(), c.green(), c.blue()))); } } px }; let starry = corner(&mut s); assert!(s.map().view.effects.stars && starry.iter().any(|p| p != &starry[0]), "stars by default"); s.run("map.effects", &json!({"stars": false})).unwrap(); let plain = corner(&mut s); assert!(plain.iter().all(|p| p == &plain[0]), "plain space"); // Saved with the project; a project from before stars has them. let p = crate::Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert!(!p.map().view.effects.stars); assert!(serde_json::from_str::("{}").unwrap().stars); s.run("map.effects", &json!({"reset": true})).unwrap(); assert!(corner(&mut s) == starry); } #[test] fn exports_draw_the_globe_in_light_space() { // #352: the dark, starry space is the interactive view's; exports and layouts print a light // one, as globe insets on a light page need. let mut s = world(); s.run("map.viewMode", &json!({"mode": "global"})).unwrap(); let rgb = |pm: &tiny_skia::Pixmap, x: u32, y: u32| pm.pixel(x, y).map(|c| (c.red(), c.green(), c.blue())).unwrap(); let corner = |s: &mut Session| { let png = crate::cmd::share::map_png(s, Some(1200.0), Some(800.0), None).unwrap(); let pm = crate::render::decode_png(&png).unwrap(); let px: Vec<_> = (0..100).flat_map(|y| (0..100).map(move |x| (x, y))).map(|(x, y)| rgb(&pm, x, y)).collect(); px }; let light = corner(&mut s); assert!(light.iter().all(|p| *p == (0xEC, 0xEF, 0xF3)), "light space without stars: {:?}", light[0]); // The Midnight basemap keeps a dark space, still without stars. s.run("map.basemap", &json!({"name": "midnight"})).unwrap(); let dark = corner(&mut s); assert!(dark.iter().all(|p| *p == (0x08, 0x0B, 0x10)), "dark space without stars: {:?}", dark[0]); s.run("map.basemap", &json!({"name": "paper"})).unwrap(); // A layout's map frame on the globe map: the space in its corner is light too. s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "blank"})).unwrap(); s.run("layout.addFrame", &json!({"x": 20, "y": 20, "w": 150, "h": 150})).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-globe-space-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("inset.png"); s.run("layout.export", &json!({"path": path.display().to_string(), "dpi": 72})).unwrap(); let pm = crate::render::decode_png(&std::fs::read(&path).unwrap()).unwrap(); let _ = std::fs::remove_dir_all(&dir); // 25 mm from the page's corner: 5 mm inside the frame's. let at = (25.0 * 72.0 / 25.4) as u32; assert_eq!(rgb(&pm, at, at), (0xEC, 0xEF, 0xF3), "a light space around the globe in the layout"); } #[test] fn view_effects_scene_and_navigation() { use crate::render::View; let mut s = world(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 48.0, "scale": 20_000_000})).unwrap(); let png = |s: &mut Session| { let v = View { width: 300.0, height: 200.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap().pixmap.data().to_vec() }; let plain = png(&mut s); for p in [ json!({"sunTime": 1_710_979_200.0}), json!({"weather": "fog"}), json!({"weather": "snow", "vignette": true, "edges": true}), json!({"tiltShift": true, "grain": true}), json!({"brightness": 20, "contrast": 10, "saturation": -50, "sepia": 30}), ] { s.run("map.effects", &p).unwrap_or_else(|e| panic!("{p}: {e}")); assert!(png(&mut s) != plain, "{p}"); } assert!(s.run("map.effects", &json!({"weather": "hail"})).is_err()); s.run("map.effects", &json!({"reset": true})).unwrap(); assert!(png(&mut s) == plain, "reset restores the map"); s.run("map.colorVision", &json!({"mode": "deuteranopia"})).unwrap(); assert!(png(&mut s) != plain); // Exports aren't simulated. let export = crate::cmd::share::map_png(&mut s, Some(300.0), Some(200.0), None).unwrap(); s.run("map.colorVision", &json!({"mode": "normal"})).unwrap(); assert!(export == crate::cmd::share::map_png(&mut s, Some(300.0), Some(200.0), None).unwrap()); s.run("map.drawingMode", &json!({"mode": "wireframe"})).unwrap(); assert!(png(&mut s) != plain); s.run("map.drawingMode", &json!({"mode": "standard"})).unwrap(); s.run("layer.priority", &json!({"layer": "Countries", "value": 5})).unwrap(); assert!(png(&mut s) != plain, "countries now draw over the cities"); s.run("layer.priority", &json!({"layer": "Countries", "value": 0})).unwrap(); s.run("map.clipping", &json!({"mode": "distance", "distance": 500_000})).unwrap(); assert!(png(&mut s) != plain); s.run("map.clipping", &json!({"mode": "none"})).unwrap(); s.run("map.displayType", &json!({"mode": "speed"})).unwrap(); assert!(png(&mut s) != plain, "no antialiasing"); s.run("map.displayType", &json!({"mode": "quality"})).unwrap(); assert!(png(&mut s) == plain); // Views and navigation. if rustgis_map::projection::local_views_supported() { s.run("map.viewMode", &json!({"mode": "local"})).unwrap(); assert!(s.map().projection.code().starts_with("local:")); let local = png(&mut s); // The local view is azimuthal equidistant, not Web Mercator under another name (#208). s.run("map.projection", &json!({"type": "mercator"})).unwrap(); assert!(png(&mut s) != local, "a local view draws in its own projection"); } else { let e = s.run("map.viewMode", &json!({"mode": "local"})).unwrap_err(); assert!(e.contains("web app"), "{e}"); } s.run("map.viewMode", &json!({"mode": "global"})).unwrap(); assert!(s.map().projection.is_globe()); s.run("map.navigation", &json!({"mode": "planar"})).unwrap(); assert!(!s.map().projection.is_globe(), "planar navigation is flat"); s.run("map.navigation", &json!({"mode": "firstPerson"})).unwrap(); let before = s.map().center_lonlat(); s.run("map.walk", &json!({"forward": 1})).unwrap(); assert!(s.map().center_lonlat().y > before.y, "walking forward goes north"); let cam = s.run("map.camera", &json!({"lon": 2.35, "lat": 48.85, "scale": 1_000_000})).unwrap(); assert!((cam["lon"].as_f64().unwrap() - 2.35).abs() < 1e-6); assert!(s.run("map.navigator", &Value::Null).unwrap()["navigator"].as_bool().unwrap()); } fn offline_field_session(layers: &[&str], empty: bool) -> Session { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; use rustgis_geom::Coord; isolate_plugins(); let mut s = Session::new(Project::default()); for name in layers.iter().rev() { let mut data = Dataset::new( *name, GeometryType::Point, vec![Field::new("name", FieldType::Text), Field::new("region", FieldType::Text), Field::new("population", FieldType::Integer)], ); if !empty { data.push_next( Some(Geometry::Point(Coord::new(1.0, 1.0))), vec![Attr::Text("Original".into()), Attr::Text("North".into()), Attr::Int(10)], ); } s.active_layer = Some(s.map_mut().add_dataset(data)); } s.run("offline.download", &json!({"name": "Fieldwork", "extent": [0.0, 0.0, 2.0, 2.0]})).unwrap(); s.run("offline.open", &json!({"name": "Fieldwork"})).unwrap(); s } #[test] fn offline_sync_rejects_incompatible_fields_and_preserves_safe_refreshes() { use rustgis_data::{FieldType, Geometry, Value as Attr}; use rustgis_geom::Coord; for mode in ["unchanged", "display_metadata", "source_refresh", "matching_schema_changes"] { let mut s = offline_field_session(&["Assets"], false); if mode != "source_refresh" { s.run("edit.setValue", &json!({"layer": "Assets", "row": 0, "field": "population", "value": 20})).unwrap(); } if mode == "matching_schema_changes" { s.run("edit.deleteField", &json!({"layer": "Assets", "field": "region"})).unwrap(); } s.run("map.activate", &json!({"name": "Map"})).unwrap(); if mode == "display_metadata" { let fields = &mut s.map_mut().layers.iter_mut().find(|l| l.name == "Assets").unwrap().dataset.fields; fields[0].name = "NAME".into(); fields[2].alias = Some("Population estimate".into()); } else if mode == "source_refresh" || mode == "matching_schema_changes" { s.run("edit.deleteField", &json!({"layer": "Assets", "field": "region"})).unwrap(); } s.run("offline.sync", &json!({"name": "Fieldwork"})).unwrap(); let source = &s.map().layer_by_name("Assets").unwrap().dataset; let expected = if mode == "source_refresh" { 10 } else { 20 }; assert_eq!(source.value(0, source.field_index("population").unwrap()), &Attr::Int(expected), "{mode}"); let offline = &s.project.maps.iter().find(|m| m.name == "Fieldwork (offline)").unwrap().layer_by_name("Assets").unwrap().dataset; assert_eq!(offline.fields, source.fields, "{mode}"); assert_eq!(offline.features, source.features, "{mode}"); // The synced copy is a map, with layers, of its own (a background run finds its map by it). let ids: Vec = s.project.maps.iter().map(|m| m.instance_id()).collect(); assert!(ids.iter().enumerate().all(|(i, a)| ids[..i].iter().all(|b| a != b)), "{mode}: {ids:?}"); let offline_map = s.project.maps.iter().find(|m| m.name == "Fieldwork (offline)").unwrap(); assert_ne!( offline_map.layer_by_name("Assets").unwrap().instance_id(), s.map().layer_by_name("Assets").unwrap().instance_id(), "{mode}" ); } for mode in ["source_deleted", "geometry_only", "offline_added", "source_reordered", "source_type_changed", "empty_offline_schema"] { let mut s = offline_field_session(&["Assets"], mode == "empty_offline_schema"); if mode == "geometry_only" { s.map_mut().layers[0].dataset.features[0].geometry = Some(Geometry::Point(Coord::new(1.1, 1.0))); } else if mode != "empty_offline_schema" { s.run("edit.setValue", &json!({"layer": "Assets", "row": 0, "field": "population", "value": 20})).unwrap(); } if mode == "offline_added" || mode == "empty_offline_schema" { s.run("edit.addField", &json!({"layer": "Assets", "name": "survey", "type": "long"})).unwrap(); } s.run("map.activate", &json!({"name": "Map"})).unwrap(); match mode { "source_deleted" | "geometry_only" => { s.run("edit.deleteField", &json!({"layer": "Assets", "field": "region"})).unwrap(); } "source_reordered" => { let source = &mut s.map_mut().layers.iter_mut().find(|l| l.name == "Assets").unwrap().dataset; source.fields.swap(0, 1); source.features[0].attrs.swap(0, 1); } "source_type_changed" => { let source = &mut s.map_mut().layers.iter_mut().find(|l| l.name == "Assets").unwrap().dataset; source.fields[2].ty = FieldType::Text; source.features[0].attrs[2] = Attr::Text("10".into()); } _ => {} } s.mark_saved(); let before = serde_json::to_value(&s.project).unwrap(); let undo_depth = s.undo_depth(); for _ in 0..2 { let error = s.run("offline.sync", &json!({"name": "Fieldwork"})).unwrap_err(); assert!(error.contains("Assets") && error.contains("fields"), "{mode}: {error}"); assert_eq!(serde_json::to_value(&s.project).unwrap(), before, "{mode}"); assert_eq!(s.undo_depth(), undo_depth, "{mode}"); assert!(!s.dirty(), "{mode}"); } } } #[test] fn offline_sync_schema_refusal_rolls_back_other_layers_and_can_retry() { use rustgis_data::Value as Attr; let mut s = offline_field_session(&["Other", "Assets"], false); assert_eq!(s.map().layers[0].name, "Other", "sync the compatible layer before reaching the incompatible one"); for (name, population) in [("Other", 99), ("Assets", 20)] { s.run("edit.setValue", &json!({"layer": name, "row": 0, "field": "population", "value": population})).unwrap(); } s.run("map.activate", &json!({"name": "Map"})).unwrap(); s.run("edit.deleteField", &json!({"layer": "Assets", "field": "region"})).unwrap(); let before = serde_json::to_value(&s.project).unwrap(); let undo_depth = s.undo_depth(); assert!(s.run("offline.sync", &json!({"name": "Fieldwork"})).is_err()); assert_eq!(serde_json::to_value(&s.project).unwrap(), before); assert_eq!(s.undo_depth(), undo_depth); // Undo and redo invalidate map geometry caches even while restoring the same project data. let undo_snapshot = |s: &Session| { let mut project = s.project.clone(); for map in &mut project.maps { for layer in &mut map.layers { layer.geometry_rev = 0; } } serde_json::to_value(project).unwrap() }; assert!(s.undo(), "restore the source field before retrying"); let before_retry = undo_snapshot(&s); let synced = s.run("offline.sync", &json!({"name": "Fieldwork"})).unwrap(); assert_eq!(synced["updated"], 2); for (name, population) in [("Other", 99), ("Assets", 20)] { let data = &s.map().layer_by_name(name).unwrap().dataset; assert_eq!(data.value(0, data.field_index("population").unwrap()), &Attr::Int(population)); } let after_retry = undo_snapshot(&s); assert!(s.undo()); assert_eq!(undo_snapshot(&s), before_retry); assert!(s.redo()); assert_eq!(undo_snapshot(&s), after_retry); } #[test] fn workspace_links_thumbnails_animation_profiles_offline_and_cache() { let dir = std::env::temp_dir().join(format!("rustgis-ws-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); // Linked views. s.run("map.new", &json!({"name": "Second"})).unwrap(); s.run("map.activate", &json!({"name": "World"})).unwrap(); s.run("view.linkViews", &json!({"on": true})).unwrap(); s.run("map.goToXY", &json!({"lon": 12.5, "lat": 41.9, "scale": 2_000_000})).unwrap(); let second = s.project.maps.iter().find(|m| m.name == "Second").unwrap(); let c = second.center_lonlat(); assert!((c.x - 12.5).abs() < 1e-6 && (second.scale() / 2_000_000.0 - 1.0).abs() < 0.01, "{c:?}"); // Thumbnails. s.run("project.thumbnail", &json!({"create": true})).unwrap(); assert!(s.project.thumbnail.as_ref().is_some_and(|t| t.len() > 100)); s.run("map.thumbnail", &json!({"image": s.project.thumbnail.clone().unwrap()})).unwrap(); assert!(s.run("map.thumbnail", &json!({"image": "bm90"})).is_err()); s.run("project.thumbnail", &json!({"clear": true})).unwrap(); assert!(s.project.thumbnail.is_none()); // Jobs. let j = s.run("job.new", &json!({"name": "Check roads", "viewExtent": true})).unwrap()["job"].as_u64().unwrap(); s.run("job.update", &json!({"job": j, "status": "in progress", "assigned": "field team"})).unwrap(); assert!(s.run("job.update", &json!({"job": j, "status": "abandoned"})).is_err()); assert_eq!(s.project.jobs[0].status, "in progress"); // Animation: two keyframes, exported as frames. s.run("animation.add", &Value::Null).unwrap(); s.run("map.goToXY", &json!({"lon": 2.35, "lat": 48.85, "scale": 4_000_000})).unwrap(); s.run("animation.add", &json!({"seconds": 1})).unwrap(); let path = dir.join("anim.zip"); s.run("animation.export", &json!({"path": path.display().to_string(), "fps": 5, "width": 160, "height": 100})).unwrap(); let frames = rustgis_data::zip::read(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(frames.len(), 6, "five frames for the second, then the last view"); assert!(frames.iter().all(|(n, b)| n.ends_with(".png") && b.starts_with(b"\x89PNG"))); s.run("animation.remove", &json!({"all": true})).unwrap(); // Profiles over the hill. let p = s.run("profile.create", &json!({"layer": dem, "points": [[10.0015, 45.0195], [10.0385, 45.0195]]})).unwrap(); assert!(p["max"].as_f64().unwrap() > 380.0 && p["gain"].as_f64().unwrap() > 150.0, "{}", p["max"]); let auto = s.run("profile.autoComplete", &json!({"layer": dem, "points": [[10.0015, 45.0195], [10.005, 45.0195]]})).unwrap(); assert!(auto["length"].as_f64().unwrap() > 2500.0, "continues to the edge"); let cross = s.run("profile.crossSection", &json!({"layer": dem, "points": [[10.0015, 45.0195], [10.0385, 45.0195]], "width": 2000})).unwrap(); assert!((cross["length"].as_f64().unwrap() - 2000.0).abs() < 50.0); s.run("layer.template", &json!({"template": "lines", "name": "Trails"})).unwrap(); s.run( "edit.addFeature", &json!({"layer": "Trails", "geometry": {"type": "LineString", "coordinates": [[10.002, 45.002], [10.038, 45.038]]}}), ) .unwrap(); let along = s.run("profile.alongLine", &json!({"layer": dem, "lines": "Trails"})).unwrap(); assert!(along["samples"].as_array().unwrap().len() > 10); assert_eq!(s.run("profile.alongSelected", &json!({"layer": dem, "lines": "Trails"})).unwrap().as_array().unwrap().len(), 1); // Infographics and diagnostics. s.run("map.goToXY", &json!({"lon": 10.0, "lat": 48.0, "scale": 20_000_000})).unwrap(); let info = s.run("info.infographic", &Value::Null).unwrap(); assert!(info["layers"].as_array().unwrap().iter().any(|c| c["layer"] == "Cities" && c["count"].as_u64().unwrap() > 10)); let d = s.run("info.diagnostics", &Value::Null).unwrap(); assert!(d["features"].as_u64().unwrap() > 100 && d["rasterPixels"].as_u64().unwrap() == 1600); // Offline: download, edit offline, sync back. let r = s.run("offline.download", &json!({"name": "Paris", "extent": [1.0, 48.0, 4.0, 50.0]})).unwrap(); assert!(r["features"].as_u64().unwrap() >= 2); s.run("offline.open", &json!({"name": "Paris"})).unwrap(); assert_eq!(s.map().name, "Paris (offline)"); s.run("select.byAttributes", &json!({"layer": "Cities", "where": "name = 'Paris'"})).unwrap(); let row = s.map().layer_by_name("Cities").unwrap().selection[0]; s.run("edit.setValue", &json!({"layer": "Cities", "row": row, "field": "pop_max", "value": 1})).unwrap(); for (name, x) in [("Field camp", 2.0), ("Field hut", 2.2)] { s.run( "edit.addFeature", &json!({"layer": "Cities", "geometry": {"type": "Point", "coordinates": [x, 49.0]}, "attributes": {"name": name}}), ) .unwrap(); } // Meanwhile France is edited in the source map; the area's untouched copy mustn't undo that. let set_france = |s: &mut Session, v: i64| { let world = s.project.maps.iter_mut().find(|m| m.name == "World").unwrap(); let countries = world.layers.iter_mut().find(|l| l.name == "Countries").unwrap(); let (ni, pi) = (countries.dataset.field_index("name").unwrap(), countries.dataset.field_index("pop_est").unwrap()); let f = countries.dataset.features.iter_mut().find(|f| f.attrs[ni].to_string() == "France").unwrap(); if v >= 0 { f.attrs[pi] = rustgis_data::Value::Int(v); } f.attrs[pi].clone() }; set_france(&mut s, 12_345); let mark = s.notice_mark(); let sync = s.run("offline.sync", &json!({"name": "Paris"})).unwrap(); let clean = s.notices_since(mark); assert!(clean.iter().all(|n| !n.text.starts_with("Offline area")), "{clean:?}"); assert_eq!((sync["updated"].as_u64(), sync["added"].as_u64()), (Some(1), Some(2)), "{sync}"); assert_eq!(set_france(&mut s, -1), rustgis_data::Value::Int(12_345), "the source edit survives the sync"); let world_map = s.project.maps.iter().find(|m| m.name == "World").unwrap(); let cities = world_map.layer_by_name("Cities").unwrap(); assert!(cities.dataset.features.iter().any(|f| f.attrs.iter().any(|v| v.to_string() == "Field camp"))); assert_eq!(sync["conflicts"], 0, "a change made only in the source is no conflict"); // Paris changed in both, and the field camp deleted offline but changed in the map: the area's // version wins, a warning names both, and Undo restores the map's. The field hut, changed only // in the map, is no conflict. let paris = |s: &mut Session, map: &str, v: Option| { let m = s.project.maps.iter_mut().find(|m| m.name == map).unwrap(); let cities = m.layers.iter_mut().find(|l| l.name == "Cities").unwrap(); let (ni, pi) = (cities.dataset.field_index("name").unwrap(), cities.dataset.field_index("pop_max").unwrap()); let f = cities.dataset.features.iter_mut().find(|f| f.attrs[ni].to_string() == "Paris").unwrap(); if let Some(v) = v { f.attrs[pi] = rustgis_data::Value::Int(v); } (f.id, f.attrs[pi].clone()) }; paris(&mut s, "Paris (offline)", Some(2)); let (id, _) = paris(&mut s, "World", Some(3)); /// The features of `map`'s Cities layer, and the row of the one named `name` among them. fn named<'a>(s: &'a mut Session, map: &str, name: &str) -> (&'a mut Vec, usize) { let m = s.project.maps.iter_mut().find(|m| m.name == map).unwrap(); let features = &mut m.layers.iter_mut().find(|l| l.name == "Cities").unwrap().dataset.features; let i = features.iter().position(|f| f.attrs.iter().any(|v| v.to_string() == name)).unwrap(); (features, i) } let (features, i) = named(&mut s, "Paris (offline)", "Field camp"); let camp_id = features.remove(i).id; let (features, i) = named(&mut s, "World", "Field camp"); features[i].geometry = Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(2.5, 49.0))); let (features, i) = named(&mut s, "World", "Field hut"); features[i].geometry = Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(2.2, 49.3))); let mark = s.notice_mark(); let sync = s.run("offline.sync", &json!({"name": "Paris"})).unwrap(); assert_eq!((sync["updated"].as_u64(), sync["deleted"].as_u64(), sync["conflicts"].as_u64()), (Some(1), Some(1), Some(2)), "{sync}"); assert_eq!(paris(&mut s, "World", None).1, rustgis_data::Value::Int(2)); let warned = s.notices_since(mark); assert!( warned.iter().any(|n| n.text.contains(&format!("Cities #{id}")) && n.text.contains(&format!("Cities #{camp_id}"))), "{warned:?}" ); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(paris(&mut s, "World", None).1, rustgis_data::Value::Int(3), "Undo restores the map's version"); let (features, i) = named(&mut s, "World", "Field camp"); assert_eq!(features[i].id, camp_id, "Undo restores the deleted field camp"); // Paris edited offline but deleted from the map meanwhile: the edit is dropped, and said. let world = s.project.maps.iter_mut().find(|m| m.name == "World").unwrap(); let cities = world.layers.iter_mut().find(|l| l.name == "Cities").unwrap(); cities.dataset.features.retain(|f| f.id != id); let mark = s.notice_mark(); let sync = s.run("offline.sync", &json!({"name": "Paris"})).unwrap(); assert_eq!(sync["skipped"], 1, "{sync}"); let warned = s.notices_since(mark); assert!(warned.iter().any(|n| n.text.contains("deleted from the map") && n.text.contains(&format!("Cities #{id}"))), "{warned:?}"); s.run("offline.remove", &json!({"name": "Paris"})).unwrap(); assert!(s.project.maps.iter().all(|m| m.name != "Paris (offline)")); // Feature cache. s.run("map.activate", &json!({"name": "World"})).unwrap(); s.run("cache.empty", &Value::Null).unwrap(); assert_eq!(s.run("info.diagnostics", &Value::Null).unwrap()["cachedLayers"], 0); s.run("cache.fill", &Value::Null).unwrap(); assert!(s.run("info.diagnostics", &Value::Null).unwrap()["cachedLayers"].as_u64().unwrap() >= 4); s.run("cache.auto", &json!({"on": true})).unwrap(); s.run("map.projection", &json!({"type": "mollweide"})).unwrap(); assert!(s.map().layers.iter().filter(|l| !l.is_raster()).all(|l| l.is_cached("mollweide"))); s.run("cache.cancel", &Value::Null).unwrap(); assert!(!s.auto_cache); let _ = std::fs::remove_dir_all(&dir); } // --------------------------------------------------------------------------------------------- // plugins /// Loads the test plugin as `id` with `perms`. fn plugin(id: &str, perms: &[&str]) { let pkg = rustgis_plugins::Package::from_zip(&rustgis_plugins::testing::package(id, perms)).unwrap(); rustgis_plugins::load(pkg, "test").unwrap(); } /// Runs the test plugin's forwarding command: it sends `op` with `params` to the host. fn forward(s: &mut Session, id: &str, op: &str, params: Value) -> crate::CmdResult { s.run(&format!("plugin:{id}/f"), &json!({"op": op, "params": params})) } #[test] fn plugin_commands_run_like_commands() { let mut s = world(); plugin("eng-view", &[]); assert_eq!(s.run("plugin:eng-view/h", &Value::Null).unwrap(), json!({"hello": "world"})); assert!(!s.can_undo(), "a plugin without 'edit' doesn't take undo snapshots"); // Reading the map needs no permission. let layers = forward(&mut s, "eng-view", "host.layers", json!({})).unwrap(); let cities = layers.as_array().unwrap().iter().find(|l| l["name"] == "Cities").unwrap(); let e: Vec = cities["extent"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(); assert!(e.len() == 4 && e[0] <= e[2] && e[1] <= e[3] && (-180.0..=180.0).contains(&e[0]) && (-90.0..=90.0).contains(&e[1]), "{cities}"); assert_eq!(cities["transparency"], 0.0, "{cities}"); assert!(cities.get("group").is_some_and(Value::is_null), "{cities}"); if let Some(l) = s.map_mut().layers.iter_mut().find(|l| l.name == "Cities") { l.transparency = 40.0; } s.run("group.add", &json!({"name": "Places", "layers": ["Cities"]})).unwrap(); let layers = forward(&mut s, "eng-view", "host.layers", json!({})).unwrap(); let cities = layers.as_array().unwrap().iter().find(|l| l["name"] == "Cities").unwrap(); assert_eq!((&cities["transparency"], &cities["group"]), (&json!(40.0), &json!("Places")), "{cities}"); let fc = forward(&mut s, "eng-view", "host.features", json!({"layer": "Cities", "limit": 2})).unwrap(); assert_eq!(fc["features"].as_array().unwrap().len(), 2); assert_eq!(fc["features"][1]["row"], 1); assert!(forward(&mut s, "eng-view", "map.zoomIn", json!({})).is_ok(), "view commands are fine"); // Changing the project isn't. let e = forward(&mut s, "eng-view", "layer.remove", json!({"layer": "Cities"})).unwrap_err(); assert!(e.contains("'edit' permission"), "{e}"); let e = forward(&mut s, "eng-view", "host.addLayer", json!({"geojson": {"type": "Point", "coordinates": [1, 2]}})).unwrap_err(); assert!(e.contains("'edit' permission"), "{e}"); assert!(s.map().layer_by_name("Cities").is_some()); // Listed with the built-in commands; unknown and disabled ones are errors. let all = s.run("info.commands", &Value::Null).unwrap(); assert!(all.as_array().unwrap().iter().any(|c| c["id"] == "plugin:eng-view/h")); assert!(s.run("plugin:eng-view/nope", &Value::Null).unwrap_err().contains("unknown command")); s.run("plugin.enable", &json!({"id": "eng-view", "enabled": false})).unwrap(); assert!(s.run("plugin:eng-view/h", &Value::Null).unwrap_err().contains("disabled")); rustgis_plugins::unload("eng-view"); } #[test] fn plugin_edits_are_one_undo_step() { let mut s = world(); plugin("eng-edit", &["edit"]); let r = forward(&mut s, "eng-edit", "host.addLayer", json!({"name": "Pins", "geojson": {"type": "Point", "coordinates": [1, 2]}})).unwrap(); assert!(r["layer"].as_u64().is_some()); assert!(s.map().layer_by_name("Pins").is_some()); assert_eq!(s.undo_label(), Some("Forward")); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layer_by_name("Pins").is_none()); forward(&mut s, "eng-edit", "layer.remove", json!({"layer": "Cities"})).unwrap(); assert!(s.map().layer_by_name("Cities").is_none()); // A failing command restores the project. let n = s.map().layers.len(); assert!(forward(&mut s, "eng-edit", "layer.remove", json!({"layer": "Nope"})).is_err()); assert_eq!(s.map().layers.len(), n); // Some things are off limits whatever the permissions. for op in ["script.run", "project.new", "edit.undo", "plugin.uninstall", "plugin:eng-edit/h", "gp.setGeolibre", "gp.uninstallGeolibre"] { assert!(forward(&mut s, "eng-edit", op, json!({})).is_err(), "{op}"); } // Files need their own permission. let e = forward(&mut s, "eng-edit", "layer.addData", json!({"path": "/etc/passwd"})).unwrap_err(); assert!(e.contains("'files' permission"), "{e}"); for (op, params) in [ ("share.exportMap", json!({})), ("project.save", json!({})), ("layer.rename", json!({"layer": "Cities", "name": "x", "output": 1})), ("layer.rename", json!({"layer": "Cities", "name": "https://example.com/x"})), ("layer.rename", json!({"layer": "Cities", "name": "../../x"})), ("layer.rename", json!({"layer": "Cities", "name": {"nested": {"url": "x"}}})), ] { let e = forward(&mut s, "eng-edit", op, params.clone()).unwrap_err(); assert!(e.contains("'files' permission"), "{op} {params}: {e}"); } assert!(forward(&mut s, "eng-edit", "layer.rename", json!({"layer": "Rivers", "name": "Streams"})).is_ok()); rustgis_plugins::unload("eng-edit"); } /// A built-in tool's output layer stays in the session, so a plugin with `edit` alone may name /// it, as a task from a file may. A name that could be a path, and tools that may write files /// (GeoLibre's, those that save one), still need `files`. #[test] fn plugin_tools_name_their_output_layer_with_edit_alone() { use crate::cmd::plugins::reaches; let mut s = world(); plugin("eng-out", &["edit"]); #[cfg(feature = "geolibre")] crate::cmd::gp::external_load_manifests( &json!([{ "id":"slope", "display_name":"Slope", "params":[ {"name":"input","data_kind":"raster","io_role":"input","required":true}, {"name":"output","data_kind":"raster","io_role":"output"} ] }]) .to_string(), ) .unwrap(); let clip = json!({"tool": "clip", "params": {"input": "Cities", "clip": "Countries", "output": "Cities in countries"}}); let gp = s.registry.get("gp.run").copied().unwrap(); assert!(reaches(&gp, &clip).is_none(), "the assistant doesn't ask either"); forward(&mut s, "eng-out", "gp.run", clip).unwrap(); assert!(s.map().layer_by_name("Cities in countries").is_some()); for params in [ json!({"tool": "clip", "params": {"input": "Cities", "clip": "Countries", "output": "out/cities"}}), json!({"tool": "clip", "params": {"input": "Cities", "clip": "Countries", "output": "cities.shp"}}), json!({"tool": "clip", "params": {"input": "Cities", "clip": "Countries", "output": "C:\\cities"}}), #[cfg(feature = "geolibre")] json!({"tool": "geolibre:slope", "params": {"input": "Cities", "output": "slope"}}), json!({"tool": "vectorToPmtiles", "params": {"input": "Cities", "minZoom": 0, "maxZoom": 0, "output": "cities"}}), ] { let e = forward(&mut s, "eng-out", "gp.run", params.clone()).unwrap_err(); assert!(e.contains("'files' permission"), "{params}: {e}"); } rustgis_plugins::unload("eng-out"); } /// The real Wasm forwarding command may run only its own tool, resolved by id or display /// name, with edit and file requirements preserved. Process tools share the same boundary. #[test] fn plugin_bridges_cannot_run_another_plugins_tools() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::plugin_bridges_cannot_run_another_plugins_tools") { return; } let mut s = world(); for id in ["eng-bridge-owner", "eng-bridge-other"] { let mut pkg = rustgis_plugins::Package::from_zip(&rustgis_plugins::testing::package(id, &["edit"])).unwrap(); pkg.manifest.tools[0].name = format!("Points for {id}"); rustgis_plugins::load(pkg, "test").unwrap(); } let before = s.map().layers.len(); for tool in ["plugin:eng-bridge-other/m", "Points for eng-bridge-other"] { let why = forward(&mut s, "eng-bridge-owner", "gp.run", json!({"tool": tool, "params": {"input": "Cities"}})).unwrap_err(); assert!(why.contains("another plugin") && why.contains("eng-bridge-other"), "{why}"); assert_eq!(s.map().layers.len(), before); } for tool in ["plugin:eng-bridge-owner/m", "Points for eng-bridge-owner"] { forward(&mut s, "eng-bridge-owner", "gp.run", json!({"tool": tool, "params": {"input": "Cities"}})).unwrap(); } assert_eq!(s.map().layers.len(), before + 2); plugin("eng-bridge-readonly", &[]); let why = forward(&mut s, "eng-bridge-readonly", "gp.run", json!({"tool": "plugin:eng-bridge-readonly/m", "params": {"input": "Cities"}})) .unwrap_err(); assert!(why.contains("'edit' permission"), "{why}"); // File-shaped parameters to an own tool still require files before the tool runs. let why = forward( &mut s, "eng-bridge-owner", "gp.run", json!({"tool": "plugin:eng-bridge-owner/m", "params": {"input": "Cities", "path": "never-opened.nc"}}), ) .unwrap_err(); assert!(why.contains("'files' permission"), "{why}"); #[cfg(not(target_arch = "wasm32"))] { let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); let manifest = rustgis_plugins::Manifest::parse(&rustgis_plugins::testing::process_manifest("eng-bridge-process", &[], &run)).unwrap(); let pkg = rustgis_plugins::Package { manifest, wasm: vec![], sha256: "test".into(), dir: Some(std::env::temp_dir()), skills: vec![] }; rustgis_plugins::load(pkg, "test").unwrap(); let why = forward(&mut s, "eng-bridge-owner", "gp.run", json!({"tool": "plugin:eng-bridge-process/m", "params": {"input": "Cities"}})) .unwrap_err(); assert!(why.contains("another plugin"), "{why}"); assert_eq!(s.map().layers.len(), before + 2, "no process tool ran"); rustgis_plugins::unload("eng-bridge-process"); } for id in ["eng-bridge-owner", "eng-bridge-other", "eng-bridge-readonly"] { rustgis_plugins::unload(id); } } /// A plugin's command the app runs leaves its downloads to the app, which fetches them off the /// frame (as the browser does): nothing waits, and nothing is an undo step until the data lands, /// with what the plugin asked (how a table's points are read) kept. Run another way (the command /// line, a script), the same command waits for the download and says how it went. #[test] fn plugin_downloads_are_left_to_the_app() { let mut s = world(); plugin("eng-url", &["edit", "network"]); let (undo, layers) = (s.undo.len(), s.map().layers.len()); // Nothing answers there: a download that waited fails. let url = "http://127.0.0.1:9/places.csv"; let p = json!({"op": "layer.addUrl", "params": {"url": url, "name": "Places", "x": "lon", "y": "lat"}}); // The command waits for the download, to run again once it is added (#1202). assert_eq!(s.run_leaving_downloads("plugin:eng-url/f", &p).unwrap(), json!({"pending": true, "downloading": [url]})); assert_eq!((s.undo.len(), s.map().layers.len()), (undo, layers)); let asked = s.take_url_requests(); assert_eq!(asked.len(), 1); assert_eq!( (asked[0].params()["name"].as_str(), asked[0].params()["x"].as_str(), asked[0].params()["y"].as_str()), (Some("Places"), Some("lon"), Some("lat")) ); assert!(asked[0].params()["request"].is_u64()); s.request_failed(asked[0].params(), "dropped"); s.poll_plugin_retries(); assert!(s.run("plugin:eng-url/f", &p).is_err(), "run otherwise, it waits"); assert!(s.take_url_requests().is_empty()); // An edit run that way is an undo step still. let p = json!({"op": "layer.rename", "params": {"layer": "Rivers", "name": "Streams"}}); s.run_leaving_downloads("plugin:eng-url/f", &p).unwrap(); assert_eq!(s.undo.len(), undo + 1); rustgis_plugins::unload("eng-url"); } /// A command that gives up says which of its downloads now won't start; one another command /// waits for still comes, so it isn't named. #[test] fn a_command_giving_up_names_the_downloads_it_leaves() { let mut s = world(); plugin("eng-giveup", &["edit", "network"]); plugin("eng-giveup-other", &["edit", "network"]); let add = |url: &str| json!({"op": "layer.addUrl", "params": {"url": url}}); s.retry_tries = crate::cmd::plugins::fetch::MAX_RETRIES; let e = s.run_leaving_downloads("plugin:eng-giveup/f", &add("https://x.org/new.csv?token=hunter2")).unwrap_err(); assert!(e.contains("gave up") && e.contains("its download of https://x.org/new.csv") && !e.contains("hunter2"), "{e}"); assert!(s.take_url_requests().is_empty(), "not started"); s.retry_tries = 0; s.run_leaving_downloads("plugin:eng-giveup-other/f", &add("https://x.org/shared.csv")).unwrap(); s.retry_tries = crate::cmd::plugins::fetch::MAX_RETRIES; let e = s.run_leaving_downloads("plugin:eng-giveup/f", &add("https://x.org/shared.csv")).unwrap_err(); assert!(e.contains("gave up") && e.contains("wasn't started"), "{e}: another plugin gets its own request"); assert_eq!(s.take_url_requests()[0].params()["url"], "https://x.org/shared.csv", "the other command's still comes"); rustgis_plugins::unload("eng-giveup"); rustgis_plugins::unload("eng-giveup-other"); } /// A plugin command whose `layer.addUrl` left a download to the app runs again once the app /// has added it, and that call then answers the layer added, so the command goes on with it /// (#1202). A failed download is the call's error then; a command clicked twice waits for the /// one download. #[test] fn a_plugin_command_runs_again_when_its_download_is_added() { let mut s = world(); plugin("eng-rerun", &["edit", "network"]); let layers = s.map().layers.len(); let url = "https://x.org/places.csv"; let p = json!({"op": "layer.addUrl", "params": {"url": url, "name": "Places", "x": "lon", "y": "lat"}}); assert_eq!(s.run_leaving_downloads("plugin:eng-rerun/f", &p).unwrap()["downloading"], json!([url])); let asked = s.take_url_requests(); assert_eq!(asked.len(), 1); assert_eq!(asked[0].params()["anonymous"], json!(true), "no saved login goes with a plugin's download"); // Clicked again while it downloads: the same download, not a second one. s.run_leaving_downloads("plugin:eng-rerun/f", &p).unwrap(); assert!(s.take_url_requests().is_empty(), "one download"); assert!(s.poll_plugin_retries().is_empty(), "nothing landed yet"); // The app downloads it and adds it, with the request's number, as its own step. let id = s.download_started_for(&asked[0]).unwrap(); assert!( s.download_landed( id, b"name,lon,lat A,10,50 B,11,51 " .to_vec() ) ); let mut add = asked[0].params().clone(); add["id"] = json!(id); s.complete_download(id, &add).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); let added = s.map().layer_by_name("Places").unwrap().id; // The command runs again: its `layer.addUrl` answers the layer added, and adds nothing (nor // an undo step of its own, having changed nothing). let undo = s.undo.len(); let ran = s.poll_plugin_retries(); assert_eq!(ran.len(), 1, "{ran:?}"); let (cmd, answer) = &ran[0]; assert_eq!(cmd, "plugin:eng-rerun/f"); assert_eq!(answer.as_ref().unwrap()["layer"], json!(added), "{answer:?}"); assert_eq!((s.map().layers.len(), s.undo.len()), (layers + 1, undo), "added once"); assert!(s.take_url_requests().is_empty() && !s.plugin_retries_waiting()); assert!(s.poll_plugin_retries().is_empty(), "run once"); // A failed download runs the command again. A known address keeps its status, even with // unescaped query whitespace; an unknown URL's secret tail is not safe to retain. for (url, reason, keeps_status) in [ // The reason adds an unknown secret query to the requested address: its value may continue. ("https://x.org/gone.csv", "https://x.org/gone.csv?token=hunter2: 404 Not Found", false), ("https://x.org/gone.csv?token=hunter2 hidden-tail", "https://x.org/gone.csv?token=hunter2 hidden-tail: 404 Not Found", true), ("https://x.org/gone.csv", "https://x.org/gone.csv?token=hunter2 hidden-tail: 404 Not Found", false), ] { let q = json!({"op":"layer.addUrl","params":{"url":url}}); s.run_leaving_downloads("plugin:eng-rerun/f", &q).unwrap(); let asked = s.take_url_requests(); s.request_failed(asked[0].params(), reason); let ran = s.poll_plugin_retries(); let e = ran[0].1.as_ref().unwrap_err(); assert!(!e.contains("hunter2") && !e.contains("hidden-tail"), "{ran:?}"); if keeps_status { assert!(e.contains("404"), "{ran:?}"); } assert!(!s.plugin_retries_waiting()); } // A native request and a plugin request get distinct engine routing numbers. let next = s.next_request + 1; s.request_url(&json!({"url": "https://x.org/other.csv", "request": next})).unwrap(); let q = json!({"op": "layer.addUrl", "params": {"url": "https://x.org/mine.csv"}}); s.run_leaving_downloads("plugin:eng-rerun/f", &q).unwrap(); let asked = s.take_url_requests(); let urls: Vec<_> = asked.iter().map(|q| (q.params()["url"].as_str(), q.params()["request"].as_u64())).collect(); assert_eq!(urls, [(Some("https://x.org/other.csv"), Some(next)), (Some("https://x.org/mine.csv"), Some(next + 1))]); // A run replaced by one asking for another address before its download was started: that // download is never started, and the notice of the replaced run says so. let first = json!({"op": "layer.addUrl", "params": {"url": "https://x.org/first.csv"}}); s.run_leaving_downloads("plugin:eng-rerun/f", &first).unwrap(); let mark = s.notice_mark(); let second = json!({"op": "layer.addUrl", "params": {"url": "https://x.org/second.csv"}}); s.run_leaving_downloads("plugin:eng-rerun/f", &second).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter().any(|t| t.contains("replaced by this one") && t.contains("its download of https://x.org/first.csv wasn't started")), "{said:?}" ); let left = s.take_url_requests(); assert!(left.iter().all(|q| q.params()["url"] != "https://x.org/first.csv"), "{} remaining requests", left.len()); rustgis_plugins::unload("eng-rerun"); } /// Retry metadata and queued capabilities obey the same outer transaction as actual plugin edits. #[test] fn plugin_download_retries_roll_back_with_their_outer_command() { let mut s = world(); plugin("eng-outer-url", &["edit", "network"]); let call = |url: &str| json!({"op":"layer.addUrl", "params":{"url":url}}); s.run_leaving_downloads("plugin:eng-outer-url/f", &call("https://x.org/kept.csv")).unwrap(); let before = serde_json::to_value(&s.project).unwrap(); let mut pending = Value::Null; let result = s.guarded("Failed plugin composite", true, |s| { pending = s.run_leaving_downloads("plugin:eng-outer-url/f", &call("https://x.org/rolled-back.csv"))?; s.run("layer.addDataText", &json!({"fileName":"broken.geojson", "text":"{"})) }); assert_eq!(pending["downloading"], json!(["https://x.org/rolled-back.csv"]), "the real plugin suspended before the outer failure"); assert!(result.is_err(), "{result:?}"); assert_eq!(serde_json::to_value(&s.project).unwrap(), before); let kept = s.take_url_requests(); assert_eq!(kept.len(), 1); assert_eq!(kept[0].params()["url"], "https://x.org/kept.csv"); assert!(s.plugin_retries_waiting() && s.poll_plugin_retries().is_empty()); s.request_failed(kept[0].params(), "cancelled"); assert_eq!(s.poll_plugin_retries().len(), 1); // An opaque request taken during a failed outer guard stays retired, not just hidden. let mut escaped = None; let result = s.guarded("Failed plugin wrapper", true, |s| { s.run_leaving_downloads("plugin:eng-outer-url/f", &call("https://x.org/escaped.csv"))?; escaped = s.take_url_requests().pop(); Err("outer command failed".into()) }); assert!(result.is_err()); let escaped = escaped.unwrap(); assert!(s.download_started_for(&escaped).is_err()); assert!(!s.plugin_retries_waiting() && s.poll_plugin_retries().is_empty() && s.take_url_requests().is_empty()); // A prior queued key consumed inside a failed guard is never restored by its checkpoint. s.request_url(&json!({"url":"https://x.org/consumed.csv"})).unwrap(); let issued = s.take_url_requests(); let result = s.guarded("Claim then fail", true, |s| { let id = s.download_started_for(&issued[0])?; s.download_failed(id); Err("failed after claim".into()) }); assert!(result.is_err() && s.download_started_for(&issued[0]).is_err()); rustgis_plugins::unload("eng-outer-url"); } /// A routing number cannot consume another session's claim or wait; a revoked original owner /// is retired before dequeue and never rerun as a fresh native User. #[test] fn plugin_download_retries_keep_their_original_owner() { use crate::policy::{Call, Effects, Outcome, Revocation, Scope}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; struct Live(AtomicBool); impl Revocation for Live { fn allows(&self, _: Effects) -> bool { self.0.load(Ordering::SeqCst) } } let mut s = world(); plugin("eng-owned-url", &["edit", "network"]); let params = json!({"op":"layer.addUrl","params":{"url":"https://x.org/owned.csv"}}); let live = Arc::new(Live(AtomicBool::new(true))); let scope = Scope::assistant(146101, live.clone()); let call = Call::command("plugin:eng-owned-url/f", params.clone()); let Outcome::NeedsApproval(prepared) = s.review(&scope, &call) else { panic!("plugin must ask") }; let pending = s.with_deferred_downloads(|s| s.run_permitted(scope, prepared.allow(), &call)).unwrap(); assert_eq!(pending["downloading"], json!(["https://x.org/owned.csv"])); assert!(s.plugin_retries_waiting(), "approved work is pending before revocation"); let before = serde_json::to_value(&s.project).unwrap(); live.0.store(false, Ordering::SeqCst); assert!(s.take_url_requests().is_empty()); let ran = s.poll_plugin_retries(); assert_eq!(ran.len(), 1); assert!(ran[0].1.is_err()); assert!(!s.plugin_retries_waiting() && s.poll_plugin_retries().is_empty()); assert_eq!(serde_json::to_value(&s.project).unwrap(), before); let mut first = world(); let mut second = world(); first.run_leaving_downloads("plugin:eng-owned-url/f", ¶ms).unwrap(); second.run_leaving_downloads("plugin:eng-owned-url/f", ¶ms).unwrap(); let foreign = first.take_url_requests(); let rightful = second.take_url_requests(); assert_eq!(foreign[0].params()["request"], rightful[0].params()["request"], "routing numbers are session-local"); assert!(second.download_started_for(&foreign[0]).is_err()); let other = Scope::assistant(146102, Arc::new(Live(AtomicBool::new(true)))); second.with_scope(other, |s| s.request_failed(rightful[0].params(), "foreign cancellation")).unwrap(); assert!(second.poll_plugin_retries().is_empty() && second.plugin_retries_waiting()); let id = second.download_started_for(&rightful[0]).unwrap(); assert!(second.download_landed(id, b"name,lon,lat\nA,1,2\n".to_vec())); let mut p = rightful[0].params().clone(); p["id"] = json!(id); second.complete_download(id, &p).unwrap(); let ran = second.poll_plugin_retries(); assert_eq!(ran.len(), 1); assert!(ran[0].1.is_ok()); assert!(second.download_started_for(&rightful[0]).is_err()); assert!(first.poll_plugin_retries().is_empty() && first.plugin_retries_waiting()); rustgis_plugins::unload("eng-owned-url"); } #[test] fn panel_input_notices_are_not_repeated_after_a_download_retry() { let id = "eng-panel-retry-notice"; let mut manifest: Value = serde_json::from_str(&rustgis_plugins::testing::panel_manifest(id)).unwrap(); manifest["permissions"] = json!(["edit", "network"]); let host_call = json!({"op":"layer.addUrl","params":{"url":"https://x.org/panel.csv","name":"Panel download","x":"lon","y":"lat"}}); let wasm = rustgis_plugins::testing::wasm_with_host_call(&host_call); assert!(!wasm.is_empty()); let package = rustgis_plugins::testing::zip_of(&[("plugin.json", manifest.to_string().as_bytes()), ("plugin.wasm", &wasm)]); rustgis_plugins::load(rustgis_plugins::Package::from_zip(&package).unwrap(), "test").unwrap(); let mut session = world(); let mark = session.notice_mark(); let params = json!({"op":"layer.addUrl","params":{"url":"https://x.org/panel.csv","name":"Panel download","x":"lon","y":"lat"},"panel":{"span":[-5,40]}}); session.run_leaving_downloads(&format!("plugin:{id}/f"), ¶ms).unwrap(); let asked = session.take_url_requests(); assert_eq!(asked.len(), 1); let download = session.download_started_for(&asked[0]).unwrap(); assert!(session.download_landed(download, b"name,lon,lat\nA,10,50\n".to_vec())); let mut add = asked[0].params().clone(); add["id"] = json!(download); session.complete_download(download, &add).unwrap(); let ran = session.poll_plugin_retries(); assert_eq!(ran.len(), 1); assert!(ran[0].1.is_ok(), "{ran:?}"); let notices = session.notices_since(mark).iter().filter(|notice| notice.text.contains("clamped or reordered")).collect::>(); assert_eq!(notices.len(), 1, "{notices:?}"); rustgis_plugins::unload(id); } #[test] fn plugin_panels_keep_their_state_between_calls() { let mut s = world(); let pkg = rustgis_plugins::Package::from_zip(&rustgis_plugins::testing::panel_package("eng-panel")).unwrap(); rustgis_plugins::load(pkg, "test").unwrap(); // Opening it runs its state command, whose answer the session keeps. let st = s.run("plugin.panel", &json!({"id": "eng-panel", "refresh": true})).unwrap(); assert_eq!(st["values"]["release"], 12); assert_eq!(st["items"]["release"]["labels"][12], "2025-12-18"); assert_eq!(st["items"]["newer"]["enabled"], false); assert!(!s.can_undo(), "a state command that changes nothing is no undo step"); // A widget's command gets the values, the one it set among them, and not as a parameter. let got = s.run("plugin:eng-panel/q", &json!({"panel": {"release": 4, "nope": 1}, "keep": true})).unwrap(); assert_eq!(got["panel"], json!({"values": {"local": false, "release": 4, "span": [2, 8]}, "changed": ["release"]})); assert_eq!(got["params"], json!({"keep": true})); assert_eq!(s.plugin_panels["eng-panel"].slider("release"), 4); let refused = |s: &Session, mark| { s.notices_since(mark).iter().filter(|n| n.text.contains("not taken")).map(|n| n.text.clone()).collect::>() }; assert_eq!(refused(&s, 0), ["Test eng-panel: 1 value(s) not taken (no such widget, or not a value it holds): nope"]); // Both host ingress paths report malformed objects and values adjusted to widget bounds. for (command, key) in [("plugin.panel", "values"), ("plugin:eng-panel/q", "panel")] { for (value, message) in [ (json!(5), "expected an object"), (json!({"release":999}), "clamped or reordered"), (json!({"span":[9,3]}), "clamped or reordered"), ] { let mut params = json!({"id":"eng-panel"}); params[key] = value; let mark = s.notice_mark(); s.run(command, ¶ms).unwrap(); let notes = s.notices_since(mark); assert_eq!(notes.len(), 1, "{command}: {notes:?}"); assert!(notes[0].text.contains("Test eng-panel") && notes[0].text.contains(message), "{notes:?}"); } let mut params = json!({"id":"eng-panel"}); params[key] = json!({"release":4,"span":[2,8]}); let mark = s.notice_mark(); s.run(command, ¶ms).unwrap(); assert!(s.notices_since(mark).is_empty(), "clean input stays quiet"); } // Any command gets them, from the ribbon or a script, and widgets that run nothing set them. let mark = s.notice_mark(); s.run("plugin.panel", &json!({"id": "eng-panel", "values": {"local": true, "release": 99}})).unwrap(); assert!(refused(&s, mark).is_empty(), "values taken (kept within the ends) are not said"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("clamped or reordered") && n.text.contains("release"))); s.run("plugin.panel", &json!({"id": "eng-panel", "values": {"span": [1, "x"]}})).unwrap(); assert_eq!(refused(&s, mark), ["Test eng-panel: 1 value(s) not taken (no such widget, or not a value it holds): span"]); let got = s.run("plugin:eng-panel/h", &json!({})).unwrap(); assert_eq!(got, json!({"hello": "world"})); let got = s.run("plugin:eng-panel/q", &json!({})).unwrap(); assert_eq!( got["panel"], json!({"values": {"local": true, "release": 12, "span": [2, 8]}, "changed": []}), "kept within the slider's ends" ); // A failed command leaves the values as they were. assert!(s.run("plugin:eng-panel/e", &json!({"panel": {"release": 1}})).is_err()); assert_eq!(s.plugin_panels["eng-panel"].slider("release"), 12); // Hostile values and answers never panic, and never leave the slider outside its ends. for junk in [json!(null), json!(7), json!("x"), json!([1]), json!({"release": "1"}), json!({"release": 1e300}), json!({"local": {}})] { let _ = s.run("plugin:eng-panel/q", &json!({"panel": junk.clone()})); let _ = s.run("plugin.panel", &json!({"id": "eng-panel", "values": junk})); let st = &s.plugin_panels["eng-panel"]; assert!((0..=12).contains(&st.slider("release"))); } // Plugins without a panel get no `panel`, and keep a `panel` parameter of their own. plugin("eng-nopanel", &[]); assert!(s.run("plugin.panel", &json!({"id": "eng-nopanel"})).unwrap_err().contains("no panel")); assert!(s.plugin_panel("eng-nopanel").is_none()); assert_eq!(forward(&mut s, "eng-nopanel", "host.info", json!({})).unwrap()["plugin"], "eng-nopanel"); assert!(s.run("plugin.panel", &json!({"id": "nope"})).is_err()); assert!(s.run("plugin.panel", &json!({})).is_err()); // A plugin can't drive panels. let e = forward(&mut s, "eng-nopanel", "plugin.panel", json!({"id": "eng-panel"})).unwrap_err(); assert!(e.contains("can't run"), "{e}"); // A disabled plugin's panel is gone. rustgis_plugins::set_enabled("eng-panel", false).unwrap(); assert!(s.plugin_panel("eng-panel").is_none()); assert!(s.run("plugin.panel", &json!({"id": "eng-panel"})).unwrap_err().contains("disabled")); rustgis_plugins::unload("eng-panel"); rustgis_plugins::unload("eng-nopanel"); } /// A pick's place reaches the plugin in degrees and in the map's coordinates, worked out by the /// session from the map it was picked on, whatever was given for them. #[test] fn plugin_panel_picks_reach_the_plugin_in_the_maps_coordinates() { let mut s = world(); let pkg = rustgis_plugins::Package::from_zip(&rustgis_plugins::testing::pick_package("eng-pick")).unwrap(); rustgis_plugins::load(pkg, "test").unwrap(); let near = |v: &Value, x: f64, tol: f64| v.as_f64().is_some_and(|v| (v - x).abs() < tol); // Web Mercator: the map's own clicks give only degrees; given map coordinates are replaced, // and said. let mark = s.notice_mark(); let got = s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": -42.8, "lat": -5.09, "x": 1, "crs": "mine"}}})).unwrap(); let said = |s: &Session, mark: u64, what: &str| s.notices_since(mark).iter().any(|n| n.text.contains(what) && n.text.contains("seed")); assert!(said(&s, mark, "replaced by the map's"), "{:?}", s.notices_since(mark)); let seed = &got["panel"]["values"]["seed"]; assert_eq!(got["panel"]["changed"], json!(["seed"]), "{got}"); assert_eq!((seed["lon"].as_f64(), seed["lat"].as_f64(), seed["crs"].as_str()), (Some(-42.8), Some(-5.09), Some("EPSG:3857")), "{seed}"); let m = crate::geom::lonlat_to_merc(crate::geom::Coord::new(-42.8, -5.09)); assert!(near(&seed["x"], m.x, 1e-6) && near(&seed["y"], m.y, 1e-6), "{seed}"); assert_eq!(s.plugin_panels["eng-pick"].values["seed"], *seed, "the panel keeps it"); // The map's own coordinates given back, or degrees alone, are quiet. let mark = s.notice_mark(); let again = json!({"panel": {"seed": seed.clone()}}); s.run("plugin:eng-pick/q-grow", &again).unwrap(); s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": -42.8, "lat": -5.09}}})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("pick(s)")), "{:?}", s.notices_since(mark)); // A box: its degrees, and the box around it in the map's coordinates. let got = s.run("plugin:eng-pick/q", &json!({"panel": {"area": {"bbox": [-44, -6, -42, -4]}}})).unwrap(); let area = &got["panel"]["values"]["area"]; let (sw, ne) = ( crate::geom::lonlat_to_merc(crate::geom::Coord::new(-44.0, -6.0)), crate::geom::lonlat_to_merc(crate::geom::Coord::new(-42.0, -4.0)), ); let e = &area["extent"]; assert!(near(&e[0], sw.x, 1e-3) && near(&e[1], sw.y, 1e-3) && near(&e[2], ne.x, 1e-3) && near(&e[3], ne.y, 1e-3), "{area}"); assert_eq!(area["bbox"], json!([-44.0, -6.0, -42.0, -4.0])); assert_eq!(got["panel"]["values"]["seed"]["lon"], -42.8, "the other places go with it"); // An EPSG projection names its code, the coordinates in its metres. s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); let got = s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": -43.5, "lat": -5.09}}})).unwrap(); let seed = &got["panel"]["values"]["seed"]; assert_eq!(seed["crs"], "EPSG:31983", "{seed}"); assert!(near(&seed["x"], 666_000.0, 5_000.0) && near(&seed["y"], 9_437_000.0, 5_000.0), "UTM 23S: {seed}"); // A preset names its definition. s.run("map.projection", &json!({"type": "robinson"})).unwrap(); let got = s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": 10, "lat": 20}}})).unwrap(); let crs = got["panel"]["values"]["seed"]["crs"].as_str().unwrap_or_default().to_string(); assert!(crs.starts_with("+proj=robin"), "{crs}"); // A widget that runs nothing keeps its place for the next request, set by `plugin.panel`. let st = s.run("plugin.panel", &json!({"id": "eng-pick", "values": {"spot": {"lon": 10, "lat": 20}}})).unwrap(); assert!(st["values"]["spot"]["x"].is_f64() && st["values"]["spot"]["crs"].is_string(), "{st}"); // Junk never panics, leaves the places as they were, and is said. let before = s.plugin_panels["eng-pick"].values.clone(); for junk in [json!({"seed": {"lon": 999, "lat": 0}}), json!({"area": {"bbox": "all"}}), json!({"seed": 7}), json!("x")] { let mark = s.notice_mark(); let _ = s.run("plugin:eng-pick/q", &json!({"panel": junk})); assert_eq!(s.plugin_panels["eng-pick"].values, before); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("not taken")), "said: {junk}"); } let mark = s.notice_mark(); let _ = s.run("plugin.panel", &json!({"id": "eng-pick", "values": {"spot": {"lon": 10, "lat": 999}}})); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("not taken") && n.text.contains("spot")), "plugin.panel says it too"); let mark = s.notice_mark(); s.run("plugin.panel", &json!({"id": "eng-pick", "values": {"spot": {"lon": 10, "lat": 20, "crs": "mine"}}})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("replaced by the map's") && n.text.contains("spot")), "and the replacing"); // The globe's map coordinates are Web Mercator's: past its latitudes they are at its edge, // and that is said. s.run("map.projection", &json!({"type": "globe"})).unwrap(); let mark = s.notice_mark(); let got = s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": 10, "lat": 89}}})).unwrap(); assert_eq!(got["panel"]["values"]["seed"]["crs"], "EPSG:3857", "{got}"); assert!(said(&s, mark, "85.05"), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("plugin:eng-pick/q", &json!({"panel": {"area": {"bbox": [0, 80, 10, 88]}}})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("85.05") && n.text.contains("area")), "a box too"); let mark = s.notice_mark(); s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": 10, "lat": 80}}})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("pick(s)")), "inside it, quiet: {:?}", s.notices_since(mark)); // A plugin answering with the place held says nothing about it. assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("not taken")), "{:?}", s.notices_since(mark)); // Another body's map: the place keeps only its degrees, and that is said. s.run("map.body", &json!({"name": "mars", "basemap": false})).unwrap(); let mark = s.notice_mark(); let got = s.run("plugin:eng-pick/q-grow", &json!({"panel": {"seed": {"lon": 10, "lat": 20, "x": 1}}})).unwrap(); assert!(got["panel"]["values"]["seed"].get("x").is_none(), "{got}"); assert!(said(&s, mark, "degrees only"), "{:?}", s.notices_since(mark)); assert!(!said(&s, mark, "replaced"), "a given x left out, not replaced: {:?}", s.notices_since(mark)); rustgis_plugins::unload("eng-pick"); } /// Loads the panel test plugin with `edit`, its panel's state command being `state`. fn panel_plugin(id: &str, state: &str) { let mut m: Value = serde_json::from_str(&rustgis_plugins::testing::panel_manifest(id)).unwrap(); m["permissions"] = json!(["edit"]); m["panel"]["state"] = json!(state); let zip = rustgis_plugins::testing::zip_of(&[("plugin.json", m.to_string().as_bytes()), ("plugin.wasm", &rustgis_plugins::testing::wasm())]); rustgis_plugins::load(rustgis_plugins::Package::from_zip(&zip).unwrap(), "test").unwrap(); } #[test] fn panel_state_commands_that_change_nothing_are_no_undo_step() { let mut s = world(); panel_plugin("eng-noop", "h"); s.run("plugin.panel", &json!({"id": "eng-noop", "refresh": true})).unwrap(); s.run("plugin:eng-noop/h", &Value::Null).unwrap(); assert!(!s.can_undo(), "its state command changed nothing"); // Its other commands keep their undo step, as plugins without a panel do. s.run("plugin:eng-noop/q", &Value::Null).unwrap(); assert_eq!(s.undo_label(), Some("Echo")); rustgis_plugins::unload("eng-noop"); plugin("eng-nopanel-noop", &["edit"]); s.undo.clear(); s.run("plugin:eng-nopanel-noop/h", &Value::Null).unwrap(); assert_eq!(s.undo_label(), Some("Hello")); rustgis_plugins::unload("eng-nopanel-noop"); } #[test] fn a_failed_panel_refresh_leaves_the_values() { let mut s = world(); panel_plugin("eng-badstate", "e"); let e = s.run("plugin.panel", &json!({"id": "eng-badstate", "values": {"local": true}, "refresh": true})); assert!(e.is_err()); assert!(!s.plugin_panels["eng-badstate"].checked("local"), "the values it set are undone"); rustgis_plugins::unload("eng-badstate"); } /// A data cube can be a Zarr store's folder or a STAC API's address, so a plugin needs both /// `files` and `network` to summarise one or work out its trend. #[test] fn plugin_data_cubes_need_files_and_network() { let mut s = world(); plugin("eng-cube-files", &["edit", "files"]); for id in ["cube.reduce", "cube.trend"] { let p = json!({"url": "https://example.com/stac/v1", "collection": "c", "asset": "NDVI", "stat": "mean"}); let e = forward(&mut s, "eng-cube-files", id, p).unwrap_err(); assert!(e.contains("'files' and 'network' permissions"), "{id}: {e}"); } rustgis_plugins::unload("eng-cube-files"); } /// A mosaic of the project's raster layers, written to no file, stays in the project: a plugin /// with `edit` alone may make one, and none without it. Writing it to a `path`, or an input read /// from disk (a raster file name that isn't a layer), needs `files`; inputs the command reads no /// file for fail in the command instead. #[test] fn plugin_mosaics_of_project_layers_need_no_files() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = world(); // A layer named like a path is still taken for a place; one named like a bare file isn't. for (name, x0) in [("west", 0.0), ("east", 1.0), ("../east.tif", 2.0), ("north.tif", 3.0)] { let gt = GeoTransform { origin_x: x0, origin_y: 1.0, pixel_width: 0.1, pixel_height: -0.1 }; let r = Raster::new(name, 10, 10, vec![vec![1.0; 100]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); } plugin("eng-mosaic-view", &[]); let e = forward(&mut s, "eng-mosaic-view", "raster.mosaic", json!({"inputs": ["west", "east"]})).unwrap_err(); assert!(e.contains("'edit' permission"), "{e}"); rustgis_plugins::unload("eng-mosaic-view"); plugin("eng-mosaic", &["edit"]); forward(&mut s, "eng-mosaic", "raster.mosaic", json!({"inputs": ["west", " east "], "name": "Elevation"})).unwrap(); assert!(s.map().layer_by_name("Elevation").is_some_and(|l| l.is_raster())); // The layer comes before a file of its name. forward(&mut s, "eng-mosaic", "raster.mosaic", json!({"inputs": ["east", "north.tif"], "name": "East and north"})).unwrap(); assert!(s.map().layer_by_name("East and north").is_some()); // Writing a file, an input read from one (a raster file name that isn't a layer), and a name // that looks like a place need `files`. for params in [ json!({"inputs": ["west", "east"], "name": "Elevation", "path": "elevation.tif"}), json!({"inputs": ["west", "east.tif"]}), json!({"inputs": ["west", "../east.tif"]}), json!({"inputs": ["west", "east"], "name": "../elevation"}), ] { let e = forward(&mut s, "eng-mosaic", "raster.mosaic", params.clone()).unwrap_err(); assert!(e.contains("'files' permission"), "{params}: {e}"); } // Inputs the command would read no file for fail in the command, as they would with `files`: // a typo, a vector layer, a non-text input, a call over the input limit. for (params, why) in [ (json!({"inputs": ["west", "esat"]}), "no raster layer 'esat'"), (json!({"inputs": ["west", "Countries"]}), "no raster layer 'Countries'"), (json!({"inputs": ["west", 3]}), "each input is a raster layer's name or a file path"), (json!({"inputs": vec!["west"; 10_001]}), "at most 10000 inputs"), ] { let e = forward(&mut s, "eng-mosaic", "raster.mosaic", params.clone()).unwrap_err(); assert!(e.contains(why) && !e.contains("permission"), "{params}: {e}"); } // So does a raster layer whose cells aren't in memory yet (a tile still downloading). let east = s.map().layer_by_name("east").unwrap().id; s.map_mut().layer_mut(east).unwrap().raster.as_mut().unwrap().data = None; let e = forward(&mut s, "eng-mosaic", "raster.mosaic", json!({"inputs": ["west", "east"]})).unwrap_err(); assert!(e.contains("no raster layer 'east'") && !e.contains("permission"), "{e}"); rustgis_plugins::unload("eng-mosaic"); } #[test] fn plugin_tile_layers_need_network() { let mut s = world(); let osm = json!({"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "attribution": "© OpenStreetMap contributors"}); // Without `network`, a plugin can't add a tile layer, directly or by importing a map file. plugin("eng-tiles-edit", &["edit", "files"]); let e = forward(&mut s, "eng-tiles-edit", "map.addTiles", osm.clone()).unwrap_err(); assert!(e.contains("'network' permission"), "{e}"); let mut m = rustgis_map::Map::new("Imported"); m.tiles.push(rustgis_map::TileLayer::new("Leak", rustgis_map::TileKind::Raster, "https://evil.example.com/{z}/{x}/{y}.png?d=secret")); let text = serde_json::to_string(&m).unwrap(); let e = forward(&mut s, "eng-tiles-edit", "map.import", json!({"text": text})).unwrap_err(); assert!(e.contains("'network' permission"), "{e}"); assert!(s.project.maps.iter().all(|m| m.tiles.is_empty())); // Nor by changing how a layer it can see fetches (here, an imported map with the same // address as an existing layer but another subdomain list). s.run("map.addTiles", &json!({"url": "https://{s}.tiles.example.com/{z}/{x}/{y}.png"})).unwrap(); let mut m = s.map().clone(); m.name = "Copy".into(); m.tiles[0].subdomains = vec!["evil".into()]; let e = forward(&mut s, "eng-tiles-edit", "map.import", json!({"text": serde_json::to_string(&m).unwrap()})).unwrap_err(); assert!(e.contains("'network' permission"), "{e}"); assert!(s.project.maps.iter().flat_map(|m| &m.tiles).all(|t| t.subdomains.is_empty())); // Changing a layer's opacity is only an edit. forward(&mut s, "eng-tiles-edit", "map.setTiles", json!({"index": 0, "opacity": 0.5})).unwrap(); assert_eq!(s.map().tiles[0].opacity, 0.5); s.run("map.removeTiles", &json!({})).unwrap(); rustgis_plugins::unload("eng-tiles-edit"); // With it (and `edit`), it can; it needs no `files` for an address. plugin("eng-tiles-net", &["edit", "network"]); forward(&mut s, "eng-tiles-net", "map.addTiles", osm).unwrap(); assert_eq!(s.map().tiles.len(), 1); let listed = forward(&mut s, "eng-tiles-net", "map.tiles", json!({})).unwrap(); assert_eq!(listed[0]["attribution"], "© OpenStreetMap contributors"); forward(&mut s, "eng-tiles-net", "map.removeTiles", json!({})).unwrap(); assert!(s.map().tiles.is_empty()); rustgis_plugins::unload("eng-tiles-net"); } /// Serves documents for `host.fetch` on a local port, counting the requests: `/json`, `/text`, /// `/latin1` (not UTF-8), `/big` (over the size cap), `/inward` (a redirect to a forbidden /// address) and `/missing` (404). Returns the base address and the count. fn serve_documents() -> (String, std::sync::Arc) { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = count.clone(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]).to_string(); let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); let (status, extra, body): (&str, String, Vec) = match path.as_str() { p if p.starts_with("/json") => { ("200 OK", String::new(), br#"{"data":[1],"select":[49059],"size":[17053],"valid":true}"#.to_vec()) } "/text" => ("200 OK", String::new(), b"plain text".to_vec()), "/latin1" => ("200 OK", String::new(), b"caf\xe9".to_vec()), "/big" => ("200 OK", String::new(), vec![b'x'; crate::cmd::plugins::fetch::MAX_BYTES + 10]), "/inward" => ("302 Found", "Location: http://169.254.169.254/latest/meta-data/\r\n".into(), Vec::new()), _ => ("404 Not Found", String::new(), b"no".to_vec()), }; let _ = write!(s, "HTTP/1.1 {status}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = s.write_all(&body); } }); (format!("http://{addr}"), count) } /// Lets this thread's plugin calls fetch from this computer (the test server) and, when `wait` /// is off, fetch as the browser does. fn fetch_mode(local: bool, wait: bool) { crate::cmd::plugins::fetch::TEST.with(|t| t.set((local, wait))); } /// `host.fetch` needs `network`, takes only public http(s) addresses (redirects too), caps the /// size, wants UTF-8, answers JSON parsed, and keeps answers. #[test] fn plugins_fetch_documents_with_network() { let mut s = world(); plugin("eng-fetch-none", &["edit"]); let e = forward(&mut s, "eng-fetch-none", "host.fetch", json!({"url": "https://example.com/x.json"})).unwrap_err(); assert!(e.contains("'network' permission"), "{e}"); rustgis_plugins::unload("eng-fetch-none"); plugin("eng-fetch", &["network"]); // Never this computer or the local network, by default; never another scheme. for bad in [ json!({}), json!({"url": 7}), json!({"url": "file:///etc/passwd"}), json!({"url": "s3://bucket/key"}), json!({"url": "ftp://example.com/"}), json!({"url": "http://127.0.0.1:9/x"}), json!({"url": "http://localhost/x"}), json!({"url": "http://192.168.0.1/"}), json!({"url": "http://169.254.169.254/latest/meta-data/"}), ] { assert!(forward(&mut s, "eng-fetch", "host.fetch", bad.clone()).is_err(), "{bad}"); } // Hostile parameters are errors, never a crash. for junk in [Value::Null, json!([1, 2]), json!({"url": null}), json!({"url": {"nested": 1}}), json!({"url": "x".repeat(100_000)})] { assert!(forward(&mut s, "eng-fetch", "host.fetch", junk.clone()).is_err(), "{junk}"); } let (base, count) = serve_documents(); fetch_mode(true, true); let a = forward(&mut s, "eng-fetch", "host.fetch", json!({"url": format!("{base}/json?r=1")})).unwrap(); assert_eq!(a["json"]["select"][0], 49059); assert!(a["text"].as_str().unwrap().starts_with('{')); let n = count.load(std::sync::atomic::Ordering::SeqCst); // Asked again: the cache answers. let again = forward(&mut s, "eng-fetch", "host.fetch", json!({"url": format!("{base}/json?r=1#ignored")})).unwrap(); assert_eq!(again, a); assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), n, "answered from the cache"); let t = forward(&mut s, "eng-fetch", "host.fetch", json!({"url": format!("{base}/text")})).unwrap(); assert_eq!(t["text"], "plain text"); assert!(t.get("json").is_none()); for (path, why) in [("/latin1", "UTF-8"), ("/big", "larger than"), ("/missing", "404"), ("/inward", "never fetched")] { let e = forward(&mut s, "eng-fetch", "host.fetch", json!({"url": format!("{base}{path}")})).unwrap_err(); assert!(e.contains(why), "{path}: {e}"); } fetch_mode(false, true); rustgis_plugins::unload("eng-fetch"); } /// Where nothing may wait (the browser), `host.fetch` answers pending: the command is undone, /// quietly, and runs again once the answer is in, when the cache answers. #[test] fn plugin_fetches_run_the_command_again_when_they_land() { let mut s = world(); plugin("eng-fetch-web", &["network", "edit"]); let (base, _) = serve_documents(); fetch_mode(true, false); let url = format!("{base}/json?web=1"); s.log.clear(); let notices = s.notice_mark(); let undo = s.undo.len(); let r = forward(&mut s, "eng-fetch-web", "host.fetch", json!({"url": url})).unwrap(); assert_eq!(r["pending"], true); assert_eq!(r["fetching"][0], url.as_str()); assert_eq!(s.undo.len(), undo, "no undo step for a run that waits"); assert!(s.notices_since(notices).is_empty(), "{:?}", s.notices_since(notices)); let t0 = std::time::Instant::now(); let ran = loop { let ran = s.poll_plugin_retries(); if !ran.is_empty() || t0.elapsed() > std::time::Duration::from_secs(10) { break ran; } std::thread::sleep(std::time::Duration::from_millis(20)); }; assert_eq!(ran.len(), 1); assert_eq!(ran[0].0, "plugin:eng-fetch-web/f"); assert_eq!(ran[0].1.as_ref().unwrap()["json"]["valid"], true); assert!(s.poll_plugin_retries().is_empty(), "run once"); assert_eq!( s.log.entries().filter(|e| e.command == "plugin:eng-fetch-web/f").count(), 1, "one actual final log entry, no pending entry" ); // A failed fetch is given to the run again as its error. let r = forward(&mut s, "eng-fetch-web", "host.fetch", json!({"url": format!("{base}/missing")})).unwrap(); assert_eq!(r["pending"], true); let t0 = std::time::Instant::now(); let ran = loop { let ran = s.poll_plugin_retries(); if !ran.is_empty() || t0.elapsed() > std::time::Duration::from_secs(10) { break ran; } std::thread::sleep(std::time::Duration::from_millis(20)); }; assert!(ran[0].1.as_ref().unwrap_err().contains("404"), "{:?}", ran[0].1); // A held pinned retry publishes revocation instead of disappearing from the UI. struct Live(std::sync::atomic::AtomicBool); impl crate::policy::Revocation for Live { fn allows(&self, _: crate::policy::Effects) -> bool { self.0.load(std::sync::atomic::Ordering::SeqCst) } } let live = std::sync::Arc::new(Live(true.into())); let scope = crate::policy::Scope::assistant(901, live.clone()); let call = crate::policy::Call::command( "plugin:eng-fetch-web/f", json!({"op":"host.fetch","params":{"url":format!("{base}/json?revoked=1")}}), ); let crate::policy::Outcome::NeedsApproval(p) = s.review(&scope, &call) else { panic!("plugin must ask") }; assert_eq!(s.run_permitted(scope, p.allow(), &call).unwrap()["pending"], true); live.0.store(false, std::sync::atomic::Ordering::SeqCst); let mark = s.notice_mark(); let started = std::time::Instant::now(); let revoked = loop { let r = s.poll_plugin_retries(); if !r.is_empty() || started.elapsed() > std::time::Duration::from_secs(10) { break r; } std::thread::yield_now(); }; assert_eq!(revoked.len(), 1); assert!(revoked[0].1.is_err()); assert!(s.notices_since(mark).iter().any(|n| n.error), "revoked retry is visible"); assert!(s.poll_plugin_retries().is_empty()); // A command forever waiting gives up. s.retry_tries = crate::cmd::plugins::fetch::MAX_RETRIES; let e = forward(&mut s, "eng-fetch-web", "host.fetch", json!({"url": format!("{base}/json?web=2")})).unwrap_err(); assert!(e.contains("gave up"), "{e}"); s.retry_tries = 0; fetch_mode(false, true); rustgis_plugins::unload("eng-fetch-web"); } #[test] fn plugin_tools_run_in_the_geoprocessing_pane() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::plugin_tools_run_in_the_geoprocessing_pane") { return; } let mut s = world(); plugin("eng-tools", &[]); let tools = s.run("gp.tools", &Value::Null).unwrap(); assert!(tools.as_array().unwrap().iter().any(|t| t["id"] == "plugin:eng-tools/m" && t["toolbox"] == "Plugins › Test eng-tools")); s.run("gp.run", &json!({"tool": "plugin:eng-tools/m", "params": {"input": "Cities"}})).unwrap(); assert_eq!(s.map().layer_by_name("Made").map(|l| l.dataset.features.len()), Some(1)); let h = s.project.gp_history.last().unwrap(); assert!(h.ok && h.messages == ["made"], "{h:?}"); s.run("gp.run", &json!({"tool": "plugin:eng-tools/x", "params": {"input": "Cities"}})).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().selection, vec![0, 2]); assert!(s.run("gp.run", &json!({"tool": "plugin:eng-tools/m", "params": {}})).unwrap_err().contains("required")); rustgis_plugins::unload("eng-tools"); } #[test] fn plugin_tools_run_off_the_session() { // This snapshot-fitting scenario needs stable accounts; connection tests rotate the // process-wide store and correctly revoke unrelated held continuations. #[cfg(not(target_arch = "wasm32"))] if std::env::var_os("RUSTGIS_PLUGIN_BG_TEST_CHILD").is_none() { let out = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "tests::plugin_tools_run_off_the_session", "--nocapture"]) .env("RUSTGIS_PLUGIN_BG_TEST_CHILD", "1") .output() .unwrap(); assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); assert!(String::from_utf8_lossy(&out.stdout).contains("test result: ok. 1 passed; 0 failed;")); return; } use crate::cmd::gp::{plugin_complete, plugin_start}; use rustgis_plugins::tools::Control; let mut s = world(); plugin("eng-bg", &[]); // Started: its layers are read now; it runs elsewhere while the project goes on. let run = plugin_start(&mut s, "plugin:eng-bg/m", &json!({"input": "Cities"})).unwrap(); assert_eq!(run.tool, "plugin:eng-bg/m"); let made = std::thread::spawn(move || { let r = run.run(&Control::default()); (run.id, r) }); s.run("layer.remove", &json!({"layer": "Cities"})).unwrap(); let (id, r) = made.join().unwrap(); plugin_complete(&mut s, id, r).unwrap(); // Applied as gp.run applies a run: its layer, its history, undo. s.run("gp.finish", &json!({"id": id})).unwrap(); assert_eq!(s.map().layer_by_name("Made").map(|l| l.dataset.features.len()), Some(1)); let h = s.project.gp_history.last().unwrap(); assert!(h.ok && h.tool == "Make Points" && h.messages == ["made"] && h.params["input"] == "Cities", "{h:?}"); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layer_by_name("Made").is_none()); // Selections too. let mut s = world(); let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); let r = run.run(&Control::default()); plugin_complete(&mut s, run.id, r).unwrap(); s.run("gp.finish", &json!({"id": run.id})).unwrap(); assert_eq!(s.map().layer_by_name("Cities").unwrap().selection, vec![0, 2]); // A cancelled run fails, whatever the tool returned, and adds nothing. let run = plugin_start(&mut s, "plugin:eng-bg/m", &json!({"input": "Cities"})).unwrap(); let control = Control::default(); control.cancel(); let r = run.run(&control); plugin_complete(&mut s, run.id, r).unwrap(); let e = s.run("gp.finish", &json!({"id": run.id})).unwrap_err(); assert_eq!(e, "Make Points: cancelled"); assert!(s.map().layer_by_name("Made").is_none()); // Parameters are checked when it starts; only plugin tools start so; a run completes once. assert!(plugin_start(&mut s, "plugin:eng-bg/m", &json!({})).err().unwrap().contains("required")); assert!(plugin_start(&mut s, "buffer", &json!({"input": "Cities"})).err().unwrap().contains("no plugin tool")); assert!(plugin_complete(&mut s, run.id, Err("late".into())).unwrap_err().contains("dropped")); // Edits made while it ran: a selection follows the features it chose, by id... let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); let ids: Vec = s.map().layer_by_name("Cities").unwrap().dataset.features.iter().map(|f| f.id).collect(); fn cities(s: &mut Session) -> &mut rustgis_map::Layer { s.map_mut().layers.iter_mut().find(|l| l.name == "Cities").unwrap() } cities(&mut s).dataset.features.remove(0); let r = run.run(&Control::default()); plugin_complete(&mut s, run.id, r).unwrap(); s.run("gp.finish", &json!({"id": run.id})).unwrap(); let l = s.map().layer_by_name("Cities").unwrap(); assert_eq!(l.selection.iter().map(|&i| l.dataset.features[i].id).collect::>(), vec![ids[2]]); // ...and an update, which would discard them, is refused. let update = |s: &Session| { let dataset = s.map().layer_by_name("Cities").unwrap().dataset.clone(); Ok(rustgis_analysis::ToolResult { output: rustgis_analysis::Output::Update { layer: "Cities".into(), dataset }, messages: vec![] }) }; let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); let r = update(&s); plugin_complete(&mut s, run.id, r).unwrap(); s.run("gp.finish", &json!({"id": run.id})).unwrap(); let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); let r = update(&s); cities(&mut s).dataset.features.remove(0); plugin_complete(&mut s, run.id, r).unwrap(); let e = s.run("gp.finish", &json!({"id": run.id})).unwrap_err(); assert_eq!(e, "Cities changed while Select Two ran; run it again"); assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), ids.len() - 2); // A layer renamed meanwhile is still the one it read; one removed isn't replaced by another // of its name. let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); cities(&mut s).name = "Towns".into(); let r = run.run(&Control::default()); plugin_complete(&mut s, run.id, r).unwrap(); s.run("gp.finish", &json!({"id": run.id})).unwrap(); assert_eq!(s.map().layer_by_name("Towns").unwrap().selection, vec![0, 2]); s.map_mut().layers.iter_mut().find(|l| l.name == "Towns").unwrap().name = "Cities".into(); let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); s.run("layer.remove", &json!({"layer": "Cities"})).unwrap(); s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Countries", "distance": 1}})).unwrap(); s.map_mut().layers.last_mut().unwrap().name = "Cities".into(); let r = run.run(&Control::default()); plugin_complete(&mut s, run.id, r).unwrap(); assert_eq!(s.run("gp.finish", &json!({"id": run.id})).unwrap_err(), "Cities was removed while Select Two ran"); // A run that won't complete can be forgotten. let run = plugin_start(&mut s, "plugin:eng-bg/x", &json!({"input": "Cities"})).unwrap(); crate::cmd::gp::plugin_abandon(&mut s, run.id); assert!(!crate::cmd::gp::plugin_waiting(&s, run.id)); // A run of a project closed while it ran doesn't land in the next one. let run = plugin_start(&mut s, "plugin:eng-bg/m", &json!({"input": "Cities"})).unwrap(); assert!(crate::cmd::gp::plugin_waiting(&s, run.id)); s.run("project.new", &Value::Null).unwrap(); assert!(!crate::cmd::gp::plugin_waiting(&s, run.id)); let r = run.run(&Control::default()); assert!(plugin_complete(&mut s, run.id, r).unwrap_err().contains("dropped")); assert!(s.run("gp.finish", &json!({"id": run.id})).is_err() && s.map().layer_by_name("Made").is_none()); rustgis_plugins::unload("eng-bg"); } #[test] fn plugin_file_parameters_reach_files_and_stay_out_of_the_history() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::plugin_file_parameters_reach_files_and_stay_out_of_the_history") { return; } use crate::cmd::plugins::{Reach, reaches}; let mut s = world(); plugin("eng-file", &["files"]); let run = json!({"tool": "plugin:eng-file/r", "params": {"model": "/data/pism.nc"}}); // Plugin identity asks each time, with or without a file parameter. let gp = s.registry.get("gp.run").copied().unwrap(); assert_eq!(reaches(&gp, &run), Some(Reach::Plugin("plugin:eng-file/r"))); assert_eq!( reaches(&gp, &json!({"tool": "plugin:eng-file/m", "params": {"input": "Cities"}})), Some(Reach::Plugin("plugin:eng-file/m")) ); // A different plugin cannot run it, regardless of its file parameters. plugin("eng-file-edit", &["edit"]); let e = forward(&mut s, "eng-file-edit", "gp.run", run).unwrap_err(); assert!(e.contains("another plugin"), "{e}"); // A file picked in the browser travels with its contents, but the history keeps its name. let picked = json!({"tool": "plugin:eng-file/r", "params": {"model": {"name": "pism.nc", "data": "Q0RGAQ=="}}}); s.run("gp.run", &picked).unwrap(); let h = s.project.gp_history.last().unwrap(); assert!(h.ok && h.params["model"] == "pism.nc", "{h:?}"); rustgis_plugins::unload("eng-file"); rustgis_plugins::unload("eng-file-edit"); } #[test] fn geopackage_plugin_writes_are_user_only_and_reload_needs_files() { use crate::cmd::plugins::{Reach, reaches}; let mut s = world(); plugin("eng-gpkg-edit", &["edit"]); for id in ["gpkg.addLayer", "gpkg.discardEdits", "layer.reloadData"] { let spec = s.registry.get(id).copied().unwrap(); assert_eq!(reaches(&spec, &json!({})), Some(Reach::Files)); let e = forward(&mut s, "eng-gpkg-edit", id, json!({})).unwrap_err(); assert!(e.contains("'files' permission"), "{id}: {e}"); } rustgis_plugins::unload("eng-gpkg-edit"); plugin("eng-gpkg-all", &["edit", "files", "network"]); let e = forward(&mut s, "eng-gpkg-all", "gpkg.saveEdits", json!({})).unwrap_err(); assert!(e.contains("plugins can't run"), "{e}"); rustgis_plugins::unload("eng-gpkg-all"); } /// This test binary, started as the process test plugin, answers one request and exits. #[test] fn plugin_process_child() { rustgis_plugins::testing::serve_if_child(); } /// An error a command says before the call that leaves a download stays, and is said once: the /// runs after it do the same again, and don't repeat it (#1202). #[test] fn a_command_run_again_does_not_repeat_its_errors() { let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-said-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::process_manifest("eng-said", &["edit", "network"], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); let said = |s: &Session, mark, text: &str| s.notices_since(mark).iter().filter(|n| n.error && n.text.contains(text)).count(); let mark = s.notice_mark(); let first = "https://x.org/first.csv"; let p = json!({"op": "layer.addUrl", "params": {"url": first}}); s.run_leaving_downloads("plugin:eng-said/d", &p).unwrap(); assert_eq!(said(&s, mark, "said before the call"), 1, "said while it waits"); let asked = s.take_url_requests(); // Each download that fails runs it again, its `layer.addUrl` answering the error: nothing more. s.request_failed(asked[0].params(), "404 Not Found"); let ran = s.poll_plugin_retries(); assert!(ran[0].1.as_ref().is_err_and(|e| e.contains("404")), "{ran:?}"); assert_eq!(said(&s, mark, "said before the call"), 1, "not repeated"); // A real guest error after the successful host call is a failure, never suspension. let failed = json!({"op":"layer.addUrl", "params":{"url":"https://x.org/not-started.csv"}, "fail":true}); assert!(s.run_leaving_downloads("plugin:eng-said/d", &failed).unwrap_err().contains("nope")); assert!(s.take_url_requests().is_empty() && !s.plugin_retries_waiting()); assert!(s.poll_plugin_retries().is_empty()); // The next time it is asked for, it is said again. let mark = s.notice_mark(); s.run_leaving_downloads("plugin:eng-said/d", &p).unwrap(); assert_eq!(said(&s, mark, "said before the call"), 1, "a new run says it"); s.plugin_retries.clear(); let _ = s.take_url_requests(); rustgis_plugins::unload("eng-said"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn process_plugins_reach_the_project_like_modules() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::process_plugins_reach_the_project_like_modules") { return; } let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-process-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); let manifest = |perms: &[&str]| rustgis_plugins::testing::process_manifest("eng-proc", perms, &run); std::fs::write(dir.join("plugin.json"), manifest(&["edit"])).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); assert_eq!(s.run("plugin:eng-proc/h", &Value::Null).unwrap(), json!({"hello": "world"})); // Its host requests go through the same checks, and its edits are one undo step. let fc = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3, 4]}}]}); forward(&mut s, "eng-proc", "host.addLayer", json!({"name": "From Process", "geojson": fc})).unwrap(); assert!(s.map().layer_by_name("From Process").is_some()); assert!(s.undo()); assert!(s.map().layer_by_name("From Process").is_none()); let e = forward(&mut s, "eng-proc", "layer.addData", json!({"path": "/etc/passwd"})).unwrap_err(); assert!(e.contains("'files' permission"), "{e}"); // Its tools run in the Geoprocessing pane. s.run("gp.run", &json!({"tool": "plugin:eng-proc/m", "params": {"input": "Cities"}})).unwrap(); assert_eq!(s.map().layer_by_name("Made").map(|l| l.dataset.features.len()), Some(1)); rustgis_plugins::unload("eng-proc"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn session_plugins_ask_between_commands_and_hear_the_map() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess", &["edit"], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); let poll_until = |s: &mut Session, done: &dyn Fn(&Session) -> bool| { let t0 = std::time::Instant::now(); while !done(s) && t0.elapsed() < std::time::Duration::from_secs(20) { s.poll_plugin_sessions(); std::thread::sleep(std::time::Duration::from_millis(20)); } }; // Long enough for a request it sent to be answered. let settle = |s: &mut Session| { for _ in 0..25 { s.poll_plugin_sessions(); std::thread::sleep(std::time::Duration::from_millis(20)); } }; // Its first command starts it, and what that command changes is told to it too. let point = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]}}]}); s.run("plugin:eng-sess/f", &json!({"op": "host.addLayer", "params": {"name": "First", "geojson": point}})).unwrap(); s.poll_plugin_sessions(); assert_eq!(s.run("plugin:eng-sess/v", &Value::Null).unwrap()["events"], json!(["layers"])); // Reads between commands, a host request or a command that changes nothing, leave the project // and the undo history as they were. s.run("plugin:eng-sess/k", &json!({"op": "host.layers", "params": {}})).unwrap(); let (revision, steps) = (s.revision, s.undo_depth()); settle(&mut s); assert_eq!((s.revision, s.undo_depth()), (revision, steps)); s.run("plugin:eng-sess/k", &json!({"op": "map.extent", "params": {}})).unwrap(); let (steps, top) = (s.undo_depth(), s.undo_label()); settle(&mut s); assert_eq!((s.undo_depth(), s.undo_label()), (steps, top)); // An edit between commands goes through the same checks, as an undo step of its own. let fc = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3, 4]}}]}); s.log.set_on(true); s.run("plugin:eng-sess/k", &json!({"op": "host.addLayer", "params": {"name": "Later", "geojson": fc}})).unwrap(); poll_until(&mut s, &|s| s.map().layer_by_name("Later").is_some()); assert_eq!(s.undo_label(), Some("Test eng-sess"), "its own step"); assert!( s.log.entries().any(|e| e.command == "host.addLayer" && e.plugin.as_deref() == Some("eng-sess")), "in the session log, as the plugin's" ); assert!(s.undo()); assert!(s.map().layer_by_name("Later").is_none()); assert!(s.redo()); assert!(s.map().layer_by_name("Later").is_some()); s.run("plugin:eng-sess/k", &json!({"op": "layer.addData", "params": {"path": "/etc/passwd"}})).unwrap(); settle(&mut s); // A fetch would wait on the app's thread: only during commands. s.run("plugin:eng-sess/k", &json!({"op": "host.fetch", "params": {"url": "https://example.org/a.json"}})).unwrap(); settle(&mut s); // Told what changed: the layers (the one it added), the view, the selection. s.map_mut().camera.pan_px(40.0, 0.0); s.run("select.rows", &json!({"layer": "Cities", "rows": [0]})).unwrap(); s.poll_plugin_sessions(); let heard = s.run("plugin:eng-sess/v", &Value::Null).unwrap(); // (The undo and redo above count as layer changes too.) let events: Vec<&str> = heard["events"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); assert!(events.first() == Some(&"layers") && events.ends_with(&["view", "selection"]), "{heard}"); let refused = heard["answers"].as_array().unwrap().iter().filter_map(|a| a["error"].as_str()).collect::>(); assert!(refused.iter().any(|e| e.contains("'files' permission")), "{heard}"); assert!(refused.iter().any(|e| e.contains("'host.fetch' is for commands")), "{heard}"); // Restyled or made see-through counts as a layer change; another project is told too. if let Some(l) = s.map_mut().layers.first_mut() { l.transparency = 0.5; } s.poll_plugin_sessions(); assert_eq!(s.run("plugin:eng-sess/v", &Value::Null).unwrap()["events"], json!(["layers"])); // So is a group renamed: `host.layers` names each layer's group. s.run("group.add", &json!({"name": "Places", "layers": ["Cities"]})).unwrap(); s.poll_plugin_sessions(); let _ = s.run("plugin:eng-sess/v", &Value::Null).unwrap(); s.map_mut().groups.iter_mut().for_each(|g| g.name = "Towns".into()); s.poll_plugin_sessions(); assert_eq!(s.run("plugin:eng-sess/v", &Value::Null).unwrap()["events"], json!(["layers"])); // Another layer made active is a layer change too: `host.layers` says which one is. let other = s.map().layers.iter().map(|l| l.id).find(|id| Some(*id) != s.active_layer).unwrap(); s.active_layer = Some(other); s.poll_plugin_sessions(); assert_eq!(s.run("plugin:eng-sess/v", &Value::Null).unwrap()["events"], json!(["layers"])); s.run("project.new", &json!({})).unwrap(); s.poll_plugin_sessions(); let events = s.run("plugin:eng-sess/v", &Value::Null).unwrap()["events"].clone(); assert!(events.as_array().unwrap().contains(&json!("project")), "{events}"); // Turned off, it stops. s.run("plugin.enable", &json!({"id": "eng-sess", "enabled": false})).unwrap(); assert!(!rustgis_plugins::session::is_running("eng-sess")); rustgis_plugins::unload("eng-sess"); let _ = std::fs::remove_dir_all(&dir); } /// Session plugins' programs are the process's, and a session's poll answers every running one: /// the tests that run them take turns, so none answers another's in its session. fn session_plugins_turn() -> std::sync::MutexGuard<'static, ()> { static TURN: std::sync::Mutex<()> = std::sync::Mutex::new(()); TURN.lock().unwrap_or_else(|e| e.into_inner()) } /// Waits (a few seconds at most) for a session plugin's program to exit, without answering it. fn wait_for_exit(plugin: &str) { let t0 = std::time::Instant::now(); while !rustgis_plugins::session::has_exited(plugin) && t0.elapsed() < std::time::Duration::from_secs(10) { std::thread::sleep(std::time::Duration::from_millis(10)); } } /// What a program sent as it exited, after many lines, is answered before the next command, as /// between commands: its edit is an undo step of its own, not part of that command's. #[test] fn a_session_programs_last_request_is_its_own_step_before_the_next_command() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-late-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-late", &["edit"], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); let fc = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3, 4]}}]}); s.run("plugin:eng-sess-late/y", &json!({"op": "host.addLayer", "params": {"name": "Late", "geojson": fc}})).unwrap(); wait_for_exit("eng-sess-late"); // No poll in between: the next command finds it exited. assert_eq!(s.run("plugin:eng-sess-late/h", &Value::Null).unwrap(), json!({"hello": "world"})); assert!(s.map().layer_by_name("Late").is_some()); assert_eq!(s.undo_label(), Some("Hello")); assert!(s.undo()); assert!(s.map().layer_by_name("Late").is_some(), "not part of the next command's step"); assert_eq!(s.undo_label(), Some("Test eng-sess-late")); assert!(s.notices_since(0).iter().all(|n| !n.text.contains("Test eng-sess-late")), "a clean exit says nothing"); rustgis_plugins::unload("eng-sess-late"); let _ = std::fs::remove_dir_all(&dir); } /// On the command line (no app to leave downloads to), a download a session program asked for /// between commands is fetched when the next command answers it: never left `pending`. #[test] fn session_plugin_downloads_between_commands_are_fetched_without_an_app() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-cli-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-cli", &["edit", "network"], &run)) .unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); s.run("plugin:eng-sess-cli/k", &json!({"op": "layer.addUrl", "params": {"url": "https://example.invalid/points.geojson"}})).unwrap(); // Each command first answers what the program sent since: `v` reports once it has. let t0 = std::time::Instant::now(); let answer = loop { let heard = s.run("plugin:eng-sess-cli/v", &Value::Null).unwrap(); if let Some(a) = heard["answers"].as_array().and_then(|a| a.first()).cloned() { break a; } assert!(t0.elapsed() < std::time::Duration::from_secs(20), "no answer"); std::thread::sleep(std::time::Duration::from_millis(50)); }; assert!(answer["error"].is_string() && answer["ok"]["pending"].is_null(), "{answer}"); assert!(s.take_url_requests().is_empty(), "nothing left for an app that isn't there"); rustgis_plugins::unload("eng-sess-cli"); let _ = std::fs::remove_dir_all(&dir); } /// A program that exits while something it left running keeps writing into its output is ended /// by the next command, and the notice says the rest wasn't answered; one that ends cleanly /// gets no notice (`a_session_programs_last_request_is_its_own_step_before_the_next_command`). #[cfg(unix)] #[test] fn a_session_program_whose_output_kept_coming_is_said() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-flow-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-flow", &[], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); s.run("plugin:eng-sess-flow/r", &Value::Null).unwrap(); wait_for_exit("eng-sess-flow"); let mark = s.notice_mark(); assert_eq!(s.run("plugin:eng-sess-flow/h", &Value::Null).unwrap(), json!({"hello": "world"})); let said: Vec = s.notices_since(mark).iter().filter(|n| n.error).map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("kept sending after it exited")), "{said:?}"); rustgis_plugins::session::stop("eng-sess-flow"); rustgis_plugins::unload("eng-sess-flow"); let _ = std::fs::remove_dir_all(&dir); } /// A program started again (the last one exited, no poll in between) takes the map as it is /// then: it isn't told of what changed before it started. #[test] fn a_restarted_session_program_starts_from_the_map_as_it_is() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-restart-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-restart", &[], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); s.run("plugin:eng-sess-restart/h", &Value::Null).unwrap(); s.poll_plugin_sessions(); // The view moves, and the program exits, with no poll between. s.map_mut().camera.pan_px(40.0, 0.0); assert_eq!(s.run("plugin:eng-sess-restart/z", &Value::Null).unwrap(), "bye"); wait_for_exit("eng-sess-restart"); // Its next command starts another, which hears nothing of the earlier move. s.run("plugin:eng-sess-restart/h", &Value::Null).unwrap(); s.poll_plugin_sessions(); assert_eq!(s.run("plugin:eng-sess-restart/v", &Value::Null).unwrap()["events"], json!([])); rustgis_plugins::session::stop("eng-sess-restart"); rustgis_plugins::unload("eng-sess-restart"); let _ = std::fs::remove_dir_all(&dir); } /// A program that failed after answering is said to have when the next command finds it, as /// the frame's poll would: the command line has no poll. #[test] fn a_session_program_found_failed_by_a_command_is_said() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-bad-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-bad", &[], &run)).unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); assert_eq!(s.run("plugin:eng-sess-bad/b", &Value::Null).unwrap(), "bye"); wait_for_exit("eng-sess-bad"); let mark = s.notice_mark(); assert_eq!(s.run("plugin:eng-sess-bad/h", &Value::Null).unwrap(), json!({"hello": "world"})); let said: Vec = s.notices_since(mark).iter().filter(|n| n.error).map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("Test eng-sess-bad stopped unexpectedly: boom")), "{said:?}"); rustgis_plugins::unload("eng-sess-bad"); let _ = std::fs::remove_dir_all(&dir); } /// A download a session plugin asks for between its commands is left to the app, as during its /// commands: the frame never waits for it. #[test] fn session_plugin_downloads_between_commands_are_left_to_the_app() { let _turn = session_plugins_turn(); let mut s = world(); let dir = std::env::temp_dir().join(format!("rustgis-test-eng-session-url-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let run = rustgis_plugins::testing::child_run("tests::plugin_process_child"); std::fs::write(dir.join("plugin.json"), rustgis_plugins::testing::session_manifest("eng-sess-url", &["edit", "network"], &run)) .unwrap(); rustgis_plugins::load(rustgis_plugins::Package::from_dir(&dir).unwrap(), "test").unwrap(); let url = "https://example.invalid/points.geojson"; s.run("plugin:eng-sess-url/k", &json!({"op": "layer.addUrl", "params": {"url": url}})).unwrap(); let t0 = std::time::Instant::now(); while s.url_requests.is_empty() && t0.elapsed() < std::time::Duration::from_secs(20) { s.poll_plugin_sessions(); std::thread::sleep(std::time::Duration::from_millis(20)); } let asked = s.take_url_requests(); let urls: Vec<&Value> = asked.iter().map(|q| &q.params()["url"]).collect(); assert!(urls.contains(&&json!(url)), "{urls:?}"); let heard = s.run("plugin:eng-sess-url/v", &Value::Null).unwrap(); assert_eq!(heard["answers"][0]["ok"]["pending"], true, "{heard}"); rustgis_plugins::unload("eng-sess-url"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn plugins_install_and_uninstall_from_files() { isolate_plugins(); let mut s = Session::new(Project::default()); let dir = std::env::temp_dir().join(format!("rustgis-test-pkg-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("eng-install.rgplugin"); std::fs::write(&file, rustgis_plugins::testing::package("eng-install", &[])).unwrap(); let info = s.run("plugin.install", &json!({"path": file.display().to_string()})).unwrap(); assert_eq!(info["id"], "eng-install"); let list = s.run("plugin.list", &Value::Null).unwrap(); assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "eng-install")); let installed = rustgis_plugins::store::dir().unwrap().join("eng-install.rgplugin"); assert!(installed.is_file(), "copied into the plugins folder"); // Loaded again at startup, remembering that it was disabled. s.run("plugin.enable", &json!({"id": "eng-install", "enabled": false})).unwrap(); rustgis_plugins::unload("eng-install"); assert!(crate::cmd::plugins::load_installed().iter().all(|e| !e.contains("eng-install"))); assert_eq!(rustgis_plugins::get("eng-install").map(|p| p.enabled), Some(false)); s.run("plugin.uninstall", &json!({"id": "eng-install"})).unwrap(); assert!(!installed.exists()); assert!(rustgis_plugins::get("eng-install").is_none()); // Uninstalling a development folder that shadows an installed package keeps the package. s.run("plugin.install", &json!({"path": file.display().to_string()})).unwrap(); let folder = dir.join("eng-install-dev"); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("plugin.json"), rustgis_plugins::testing::manifest("eng-install", &[])).unwrap(); std::fs::write(folder.join("plugin.wasm"), rustgis_plugins::testing::wasm()).unwrap(); s.run("plugin.install", &json!({"path": folder.display().to_string()})).unwrap(); s.run("plugin.uninstall", &json!({"id": "eng-install"})).unwrap(); assert!(installed.is_file(), "the installed package stays"); assert!(crate::cmd::plugins::load_installed().iter().all(|e| !e.contains("eng-install"))); assert_eq!(rustgis_plugins::get("eng-install").map(|p| p.source.clone()), Some(installed.display().to_string())); s.run("plugin.uninstall", &json!({"id": "eng-install"})).unwrap(); assert!(!installed.exists()); // A file that isn't a package isn't installed. std::fs::write(dir.join("bad.rgplugin"), b"nope").unwrap(); assert!(s.run("plugin.install", &json!({"path": dir.join("bad.rgplugin").display().to_string()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn registry_downloads_are_checked() { let bytes = rustgis_plugins::testing::package("eng-reg", &[]); let mut e = rustgis_plugins::registry::Entry { id: "eng-reg".into(), name: "Reg".into(), version: "1.0.0".into(), description: String::new(), author: String::new(), homepage: String::new(), license: String::new(), categories: Vec::new(), package: "https://example.com/x.rgplugin".into(), sha256: "0".repeat(64), size: 0, min_rustgis_version: String::new(), permissions: Vec::new(), i18n: Default::default(), runtime: rustgis_plugins::manifest::Runtime::Wasm, python: None, }; isolate_plugins(); assert!(crate::cmd::plugins::install_checked(&e, &bytes).unwrap_err().contains("doesn't match")); e.sha256 = rustgis_plugins::package::sha256_hex(&bytes); e.id = "other".into(); assert!(crate::cmd::plugins::install_checked(&e, &bytes).unwrap_err().contains("not 'other'")); e.id = "eng-reg".into(); // What the registry said about how it runs (shown before installing) must be so. e.runtime = rustgis_plugins::manifest::Runtime::Process; let err = crate::cmd::plugins::install_checked(&e, &bytes).unwrap_err(); assert!(err.contains("is a WebAssembly plugin, but the registry lists it as a process plugin"), "{err}"); assert_eq!(crate::cmd::plugins::entry_status(&e)["runtime"], "process"); e.runtime = rustgis_plugins::manifest::Runtime::Wasm; crate::cmd::plugins::install_checked(&e, &bytes).unwrap(); assert_eq!(crate::cmd::plugins::entry_status(&e)["installed"], "1.0.0"); assert_eq!(crate::cmd::plugins::entry_status(&e)["runtime"], "wasm"); let _ = rustgis_plugins::store::uninstall("eng-reg"); } #[test] fn reports_group_summarise_relate_export_and_reopen() { let dir = std::env::temp_dir().join(format!("rustgis-report-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); assert!(s.run("report.autofill", &Value::Null).is_err(), "no report yet"); let cities = s.map().layer_by_name("Cities").unwrap(); let fields: Vec = cities.dataset.fields.iter().map(|f| f.name.clone()).collect(); let numeric = cities.dataset.fields.iter().find(|f| f.name.to_ascii_lowercase().starts_with("pop")).unwrap().name.clone(); let n = cities.dataset.features.len(); s.run( "report.new", &json!({"layer": "Cities", "name": "Cities", "fields": ["name", numeric], "groups": ["adm0name"], "sort": numeric, "descending": true, "page": "a4"}), ) .unwrap(); assert_eq!((s.project.reports[0].width, s.project.reports[0].height), (210.0, 297.0)); assert!(s.run("report.field", &json!({"field": "no_such_field"})).is_err()); s.run("report.statistics", &json!({"field": numeric, "kind": "sum"})).unwrap(); s.run("report.chart", &json!({"category": "adm0name"})).unwrap(); s.run("report.supplementalPage", &json!({"title": "About", "text": "Cities of the world."})).unwrap(); s.run("report.newTemplate", &json!({"end": true})).unwrap(); assert!(s.active_layout.is_none(), "adding a layout page leaves the map view showing"); // The blocks: a heading per country, the sum, every city once. let doc = crate::cmd::report::build(&s, 0).unwrap(); use rustgis_map::report::Block; let rows = doc.blocks.iter().filter(|b| matches!(b, Block::Row(_))).count(); assert_eq!(rows, n); assert!(doc.blocks.iter().any(|b| matches!(b, Block::Heading { level: 0, .. }))); assert!(doc.blocks.iter().any(|b| matches!(b, Block::Stats(v) if v.iter().any(|l| l.starts_with("Sum of"))))); assert!(doc.blocks.iter().any(|b| matches!(b, Block::Chart { bars, .. } if !bars.is_empty()))); assert!(matches!(doc.blocks.last(), Some(Block::Layout(_)))); // A report on countries with each country's cities beneath it, followed by the cities report. s.run("data.relate", &json!({"name": "Country of city", "origin": "Countries", "originKey": "name", "destination": "Cities", "destinationKey": "adm0name"})) .unwrap(); s.run("report.new", &json!({"layer": "Countries", "name": "Countries", "fields": ["name"], "where": "name LIKE 'F%'"})).unwrap(); s.run("report.subreport", &json!({"relationship": "Country of city", "fields": ["name"]})).unwrap(); s.run("report.relatedReport", &json!({"related": "Cities"})).unwrap(); assert!(s.run("report.relatedReport", &json!({"related": "Countries"})).is_err(), "not itself"); let doc = crate::cmd::report::build(&s, 1).unwrap(); assert!(doc.blocks.iter().any(|b| matches!(b, Block::SubRow(_))), "France's cities are listed under it"); assert!(doc.blocks.iter().filter(|b| matches!(b, Block::Row(_))).count() > n, "the cities report follows"); // Moving a report to the other end of its subreport's relationship drops the related fields // the new related layer lacks. s.run("report.new", &json!({"layer": "Countries", "name": "Swap"})).unwrap(); s.run("report.subreport", &json!({"relationship": "Country of city", "fields": ["name", "adm0name"]})).unwrap(); s.run("report.update", &json!({"layer": "Cities"})).unwrap(); let swap = s.project.reports.iter().find(|r| r.name == "Swap").unwrap(); assert!(swap.sections.iter().any(|x| matches!(x, rustgis_map::report::Section::Subreport { fields, .. } if fields == &["name"]))); s.run("report.remove", &Value::Null).unwrap(); // Picture paths are read relative to the saved project's folder only. let png = { let mut pm = tiny_skia::Pixmap::new(4, 4).unwrap(); pm.fill(tiny_skia::Color::from_rgba8(200, 0, 0, 255)); pm.encode_png().unwrap() }; std::fs::write(dir.join("pic.png"), &png).unwrap(); let pics = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"photo": "pic.png"}, "geometry": {"type": "Point", "coordinates": [0, 0]}}, {"type": "Feature", "properties": {"photo": dir.join("pic.png").to_str().unwrap()}, "geometry": {"type": "Point", "coordinates": [1, 0]}}, {"type": "Feature", "properties": {"photo": "../pic.png"}, "geometry": {"type": "Point", "coordinates": [2, 0]}}]}); s.run("layer.addDataText", &json!({"fileName": "pics.geojson", "text": pics.to_string()})).unwrap(); s.run("report.new", &json!({"layer": "pics", "name": "Pics"})).unwrap(); s.run("report.dynamicPicture", &json!({"field": "photo"})).unwrap(); let pi = s.project.reports.iter().position(|r| r.name == "Pics").unwrap(); let count = |s: &Session| crate::cmd::report::build(s, pi).unwrap().blocks.iter().filter(|b| matches!(b, Block::Picture { .. })).count(); assert_eq!(count(&s), 0, "an unsaved project reads no picture files"); s.run("project.saveAs", &json!({"path": dir.join("p.rgis").to_str().unwrap()})).unwrap(); assert_eq!(count(&s), 1, "only the relative path inside the project folder"); // Renaming a report keeps links to it; exports are .pdf or .png only. s.run("report.update", &json!({"report": "Cities", "name": "World cities"})).unwrap(); let countries = s.project.reports.iter().find(|r| r.name == "Countries").unwrap(); assert!(countries.sections.iter().any(|x| matches!(x, rustgis_map::report::Section::Related { report } if report == "World cities"))); assert!(s.run("report.export", &json!({"report": "Countries", "path": dir.join("c.jpg").to_str().unwrap()})).is_err()); s.run("report.update", &json!({"report": "World cities", "name": "Cities"})).unwrap(); // Sorting a column that mixes numbers and text (9, 10, "1a"…) is a total order, never a panic. let mixed = json!({"type": "FeatureCollection", "features": (0..200).map(|i| json!({"type": "Feature", "properties": {"v": if i % 3 == 0 { json!(format!("{}a", i % 7)) } else { json!(i % 11) }}, "geometry": {"type": "Point", "coordinates": [i as f64 * 0.01, 0.0]}})).collect::>()}); s.run("layer.addDataText", &json!({"fileName": "mixed.geojson", "text": mixed.to_string()})).unwrap(); s.run("report.new", &json!({"layer": "mixed", "name": "Mixed", "sort": "v", "groups": ["v"]})).unwrap(); let i = s.project.reports.iter().position(|r| r.name == "Mixed").unwrap(); assert!(crate::cmd::report::build(&s, i).is_ok()); // A related report that loops back stops. s.run("report.relatedReport", &json!({"report": "Cities", "related": "Countries"})).unwrap(); assert!(crate::cmd::report::build(&s, 1).is_ok()); // Export: PDF pages and a PNG. let pdf = dir.join("countries.pdf"); let r = s.run("report.export", &json!({"report": "Countries", "path": pdf.to_str().unwrap(), "dpi": 72})).unwrap(); assert!(r["pages"].as_u64().unwrap() >= 3); assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF")); let png = dir.join("cities.png"); s.run("report.export", &json!({"report": "Cities", "path": png.to_str().unwrap(), "dpi": 72})).unwrap(); assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG")); // Report file and template, reopened. let file = dir.join("cities.rgisreport"); let tmpl = dir.join("cities.rgisrpttemplate"); s.run("report.save", &json!({"report": "Cities", "path": file.to_str().unwrap()})).unwrap(); s.run("report.saveTemplate", &json!({"report": "Cities", "path": tmpl.to_str().unwrap()})).unwrap(); let r = s.run("report.open", &json!({"path": file.to_str().unwrap()})).unwrap(); assert_eq!(r["name"], "Cities 2"); s.run("report.open", &json!({"path": tmpl.to_str().unwrap(), "layer": "Cities"})).unwrap(); let t = s.project.reports.last().unwrap(); assert_eq!(t.layer, "Cities"); assert!(t.sections.iter().all(|x| matches!(x, rustgis_map::report::Section::Page { .. }))); // Items, undo and fields kept. let (name, bytes) = crate::cmd::items::item_bytes(&mut s, "reportTemplate", &json!({"report": "Cities"})).unwrap(); assert!(name.ends_with(".rgisrpttemplate") && !bytes.is_empty()); assert!(fields.iter().any(|f| f == "adm0name")); let before = s.project.reports.len(); s.run("report.remove", &json!({"report": "Cities 2"})).unwrap(); assert_eq!(s.project.reports.len(), before - 1); s.undo(); assert_eq!(s.project.reports.len(), before); let _ = std::fs::remove_dir_all(&dir); } #[test] fn presentations_and_notebooks() { let dir = std::env::temp_dir().join(format!("rustgis-pres-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); assert!(s.run("presentation.mapPage", &Value::Null).is_err(), "no presentation yet"); s.run("presentation.new", &json!({"name": "Tour", "title": "World tour"})).unwrap(); s.run("map.goToXY", &json!({"lon": 12.5, "lat": 41.9, "scale": 5_000_000})).unwrap(); s.run("presentation.mapPage", &json!({"title": "Rome"})).unwrap(); s.run("presentation.blankPage", &json!({"title": "Facts", "text": "Rome is the capital of Italy.", "background": "#FFF8E0"})).unwrap(); assert!(s.run("presentation.blankPage", &json!({"background": "beige"})).is_err()); let png = s.run("project.thumbnail", &json!({"create": true})).map(|_| s.project.thumbnail.clone().unwrap()).unwrap(); s.run("presentation.imagePage", &json!({"image": png, "caption": "The map", "at": 1})).unwrap(); assert!(s.run("presentation.imagePage", &json!({"image": "bm90"})).is_err()); s.run("presentation.videoPage", &json!({"source": "https://example.org/rome.mp4", "posterImage": png})).unwrap(); s.run("presentation.videoPage", &json!({"source": "C:\\videos\\walk.webm"})).unwrap(); let pres = &s.project.presentations[0]; let kinds: Vec<&str> = pres.pages.iter().map(|p| p.kind.code()).collect(); assert_eq!(kinds, ["blank", "image", "map", "blank", "video", "video"]); assert_eq!(pres.pages[5].title, "walk.webm"); s.run("presentation.updatePage", &json!({"page": 2, "notes": "Zoom in", "seconds": 9, "moveTo": 0})).unwrap(); assert_eq!(s.project.presentations[0].pages[0].title, "Rome"); assert!(s.run("presentation.updatePage", &json!({"page": 99})).is_err()); // The map page draws the map: not one flat colour. let img = crate::cmd::presentation::draw_page(&mut s, 0, 0, 40.0).unwrap(); let first = img.pixels()[img.pixels().len() / 2]; assert!(img.pixels().iter().any(|p| *p != first)); // Exports. for (file, magic) in [("tour.pdf", &b"%PDF"[..]), ("tour.html", &b""[..]), ("tour.png", &b"\x89PNG"[..])] { let path = dir.join(file); let r = s.run("presentation.export", &json!({"path": path.to_str().unwrap(), "dpi": 48})).unwrap(); assert_eq!(r["pages"], if file.ends_with(".png") { 1 } else { 6 }); assert!(std::fs::read(&path).unwrap().starts_with(magic), "{file}"); } assert!(s.run("presentation.export", &json!({"path": dir.join("deck.pptx").to_str().unwrap()})).is_err()); let html = std::fs::read_to_string(dir.join("tour.html")).unwrap(); use crate::cmd::presentation::playable; for (source, kept) in [ ("https://a.org/v.mp4", true), ("clip.mp4", true), ("C:\\v.mp4", true), ("/v/a.mp4", true), ("file:///etc/v", false), ("data:x", false), ] { assert_eq!(playable(source).is_empty(), !kept, "{source}"); } assert!(html.contains("2"]}}, {"output_type": "display_data", "metadata": {}, "data": {"text/plain": "
", "image/png": png}} ]}, {"cell_type": "code", "id": "boom", "metadata": {}, "execution_count": 4, "source": "1 / 0", "outputs": [{"output_type": "error", "ename": "ZeroDivisionError", "evalue": "division by zero", "traceback": ["\u{1b}[0;31mZeroDivisionError\u{1b}[0m: division by zero"]}]}, {"cell_type": "raw", "id": "raw", "metadata": {"format": "text/x-rst"}, "source": ".. note:: raw"} ] }); let mut s = world(); s.run("notebook.open", &json!({"text": original.to_string()})).unwrap(); let nb = s.project.notebooks.last().unwrap().clone(); assert_eq!(nb.language(), Some("python")); // Rich outputs read as text, with the image kept for the pane. let out = nb.cells[1].output.as_deref().unwrap_or_default(); assert!(out.contains("loading") && out.contains('2') && out.contains("
"), "{out:?}"); assert!(!nb.cells[1].error); assert_eq!(nb.cells[1].images(), vec![png]); let err = nb.cells[2].output.as_deref().unwrap_or_default(); assert!(nb.cells[2].error && err.contains("ZeroDivisionError: division by zero") && !err.contains('\u{1b}'), "{err:?}"); // A stream after a result starts its own line; consecutive streams join. let outputs = json!([ {"output_type": "execute_result", "data": {"text/plain": "2"}}, {"output_type": "stream", "name": "stdout", "text": "a"}, {"output_type": "stream", "name": "stdout", "text": "b"} ]); // A kernel known only by its name, or with a blank language, is still Python. for meta in [json!({"kernelspec": {"name": "python3"}}), json!({"kernelspec": {"language": " "}, "language_info": {"name": "python"}})] { let text = json!({"nbformat": 4, "metadata": meta, "cells": [{"cell_type": "code", "source": "x", "outputs": outputs}]}); s.run("notebook.open", &json!({"text": text.to_string()})).unwrap(); let opened = s.project.notebooks.last().unwrap(); assert_eq!(opened.language(), Some("python"), "{meta}"); assert_eq!(opened.cells[0].output.as_deref(), Some("2\nab")); s.run("notebook.remove", &json!({"notebook": s.project.notebooks.len() - 1})).unwrap(); } // A notebook with more cells than RustGIS holds is refused rather than cut short. let many = json!({"nbformat": 4, "cells": vec![json!({"cell_type": "markdown", "source": "x"}); 1001]}); let e = s.run("notebook.open", &json!({"text": many.to_string()})).unwrap_err(); assert!(e.contains("1001 cells"), "{e}"); // So is one too large in RustGIS's own format. let many = json!({"name": "Big", "cells": vec![json!({"kind": "markdown", "source": "x"}); 1001]}); assert!(s.run("notebook.open", &json!({"text": many.to_string()})).unwrap_err().contains("1001 cells")); // Nor one with a cell that isn't an object. let odd = json!({"nbformat": 4, "cells": [{"cell_type": "markdown", "source": "a"}, 7]}); assert!(s.run("notebook.open", &json!({"text": odd.to_string()})).is_err()); // Nor is an nbformat 3 notebook, whose cells are in worksheets. let v3 = json!({"nbformat": 3, "worksheets": [{"cells": [{"cell_type": "code", "input": "1"}]}]}); assert!(s.run("notebook.open", &json!({"text": v3.to_string()})).unwrap_err().contains("nbformat 3")); // Nor is a cell longer than RustGIS keeps. let long = json!({"nbformat": 4, "cells": [{"cell_type": "code", "source": "x".repeat(200_001)}]}); assert!(s.run("notebook.open", &json!({"text": long.to_string()})).unwrap_err().contains("cell 1")); let long = json!({"name": "Long", "cells": [{"kind": "code", "source": "x".repeat(200_001)}]}); assert!(s.run("notebook.open", &json!({"text": long.to_string()})).unwrap_err().contains("cell 1")); // A cell that removes an earlier one writes its result to itself, not to the cell that moved // into its place. s.run("notebook.new", &json!({"name": "Shift"})).unwrap(); s.run("notebook.editCell", &json!({"cell": 1, "source": "notebook.removeCell {\"notebook\": \"Shift\", \"cell\": 0}"})).unwrap(); s.run("notebook.addCell", &json!({"source": "map.goToXY {\"lon\": 1, \"lat\": 1}"})).unwrap(); s.run("notebook.run", &json!({"cell": 1})).unwrap(); let shift = s.project.notebooks.last().unwrap(); assert_eq!(shift.cells.len(), 2); assert_eq!(shift.cells[1].output, None, "the cell that moved up keeps its own (empty) output"); assert!(shift.cells[0].output.as_deref().is_some_and(|o| o.starts_with("notebook.removeCell")), "{:?}", shift.cells[0].output); s.run("notebook.remove", &json!({"notebook": s.project.notebooks.len() - 1})).unwrap(); // Junk where a Jupyter notebook has its parts is skipped or refused, never a panic. for junk in [ json!({"nbformat": 4, "cells": 5}), json!({"nbformat": 4, "cells": [1, "x", null, {"cell_type": 7, "source": {"a": 1}, "outputs": "no"}]}), json!({"nbformat": "4", "nbformat_minor": "5", "metadata": "x", "cells": [{"cell_type": "code", "outputs": [1, {"output_type": "error", "traceback": [1, null]}, {"output_type": "display_data", "data": 3}]}]}), json!({"nbformat": 4, "metadata": {"kernelspec": 1, "language_info": [1]}, "cells": [{"cell_type": "code", "id": 9, "outputs": [{"data": {"image/png": [1, 2]}}]}]}), ] { if s.run("notebook.open", &json!({"text": junk.to_string()})).is_ok() { let nb = s.project.notebooks.last().unwrap(); let _ = (nb.language_label(), nb.cells.iter().map(crate::cmd::notebook::Cell::images).count()); let i = s.project.notebooks.len() - 1; assert!(crate::cmd::notebook::notebook_file(&mut s, i, true).is_ok(), "{junk}"); s.run("notebook.remove", &json!({"notebook": i})).unwrap(); } } // A command cell from Jupyter that runs here replaces its Jupyter outputs with the result. let cmds = json!({"nbformat": 4, "nbformat_minor": 5, "metadata": {"language_info": {"name": "rustgis"}}, "cells": [ {"cell_type": "code", "id": "go", "metadata": {}, "execution_count": 7, "source": "map.goToXY {\"lon\": 3, \"lat\": 3}", "outputs": [{"output_type": "stream", "name": "stdout", "text": "old\n"}]}]}); s.run("notebook.open", &json!({"text": cmds.to_string()})).unwrap(); s.run("notebook.run", &json!({"cell": 0})).unwrap(); let i = s.project.notebooks.len() - 1; let saved: Value = serde_json::from_slice(&crate::cmd::notebook::notebook_file(&mut s, i, true).unwrap()).unwrap(); let cell = &saved["cells"][0]; assert_eq!(cell["id"], "go"); assert_eq!(cell["execution_count"], Value::Null); let text = crate::cmd::notebook::jupyter_text(&cell["outputs"][0]["text"]); assert!(text.starts_with("map.goToXY") && !text.contains("old"), "{text}"); s.run("notebook.remove", &json!({"notebook": i})).unwrap(); // A Jupyter notebook that names no language isn't run as commands either. let bare = json!({"nbformat": 4, "cells": [{"cell_type": "code", "source": "map.goToXY {\"lon\": 9, \"lat\": 9}"}]}); s.run("notebook.open", &json!({"text": bare.to_string()})).unwrap(); assert_eq!(s.project.notebooks.last().unwrap().language(), Some("code")); // A language from the file is kept short when shown. let long = json!({"nbformat": 4, "metadata": {"kernelspec": {"language": "q".repeat(1000)}}, "cells": []}); s.run("notebook.open", &json!({"text": long.to_string()})).unwrap(); assert_eq!(s.project.notebooks.last().unwrap().language_label().map(|l| l.chars().count()), Some(32)); s.run("notebook.remove", &json!({"notebook": s.project.notebooks.len() - 1})).unwrap(); s.run("notebook.activate", &json!({"notebook": s.project.notebooks.len() - 1})).unwrap(); assert_eq!(s.run("notebook.run", &Value::Null).unwrap()["skipped"], 1); s.run("notebook.remove", &json!({"notebook": s.project.notebooks.len() - 1})).unwrap(); s.run("notebook.activate", &json!({"notebook": nb.name.as_str()})).unwrap(); // Python cells aren't run as commands: running them all skips them, one alone says why. let r = s.run("notebook.run", &Value::Null).unwrap(); assert_eq!(r["ran"], 0); assert_eq!(r["skipped"], 2); let e = s.run("notebook.run", &json!({"cell": 1})).unwrap_err(); assert!(e.contains("Python"), "{e}"); assert_eq!(s.project.notebooks.last().unwrap(), &nb, "skipped cells keep their outputs"); // Saving keeps the kernel, metadata, ids, raw cells and every output, so Jupyter can run it. let dir = std::env::temp_dir().join(format!("rustgis-jupyter-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("rivers.ipynb"); s.run("notebook.save", &json!({"path": path.to_str().unwrap()})).unwrap(); let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(saved["nbformat_minor"], 5); for key in ["kernelspec", "language_info", "widgets"] { assert_eq!(saved["metadata"][key], original["metadata"][key], "{key}"); } let (sc, oc) = (saved["cells"].as_array().unwrap(), original["cells"].as_array().unwrap()); assert_eq!(sc.len(), oc.len()); for (a, b) in sc.iter().zip(oc) { for key in ["cell_type", "id", "metadata", "outputs", "execution_count"] { assert_eq!(a[key], b[key], "{key} of cell {}", b["id"]); } assert_eq!(crate::cmd::notebook::jupyter_text(&a["source"]), crate::cmd::notebook::jupyter_text(&b["source"])); } // An edited cell keeps its outputs; a new cell gets a fresh, unique id. s.run("notebook.editCell", &json!({"cell": 1, "source": "1 + 2"})).unwrap(); s.run("notebook.addCell", &json!({"source": "print('hi')"})).unwrap(); s.run("notebook.save", &json!({"path": path.to_str().unwrap()})).unwrap(); let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(saved["cells"][1]["outputs"], original["cells"][1]["outputs"]); let id = saved["cells"][4]["id"].as_str().unwrap(); assert!(!id.is_empty() && !["intro", "load", "boom", "raw"].contains(&id), "{id}"); assert_eq!(saved["cells"][4]["outputs"], json!([])); // A RustGIS command notebook saved as Jupyter still opens as commands. s.run("notebook.new", &json!({"name": "Commands"})).unwrap(); s.run("notebook.save", &json!({"path": path.to_str().unwrap()})).unwrap(); s.run("notebook.open", &json!({"path": path.to_str().unwrap()})).unwrap(); assert_eq!(s.project.notebooks.last().unwrap().language(), None); // And a notebook kept in a project keeps its Jupyter details. let i = s.project.notebooks.iter().position(|n| n.name == nb.name).unwrap(); let back: crate::cmd::notebook::Notebook = serde_json::from_value(serde_json::to_value(&s.project.notebooks[i]).unwrap()).unwrap(); assert_eq!(back.language(), Some("python")); std::fs::remove_dir_all(&dir).ok(); } /// A three-band raster: water-like pixels on the left half, field-like on the right. fn two_class_b64() -> String { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: 10.0, origin_y: 45.04, pixel_width: 0.001, pixel_height: -0.001 }; let band = |water: f32, field: f32| -> Vec { (0..1600).map(|i| if i % 40 < 20 { water + (i % 7) as f32 } else { field + (i % 5) as f32 }).collect() }; let r = Raster::new( "scene", 40, 40, vec![band(20.0, 120.0), band(80.0, 30.0), band(140.0, 60.0)], gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt8", ) .unwrap(); base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()) } /// The layers a report export reads whole are those its pages list: its own, and those of the /// reports it appends as far as they are drawn, not one appended past the depth drawn. #[test] fn a_report_export_reads_only_the_layers_it_draws() { let mut s = world(); let point = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"name": "a"}, "geometry": {"type": "Point", "coordinates": [0, 0]}}]}); s.run("layer.addDataText", &json!({"fileName": "deep.geojson", "text": point.to_string()})).unwrap(); let layers = ["Cities", "Countries", "Rivers", "Lakes", "deep"]; for (k, layer) in layers.iter().enumerate() { s.run("report.new", &json!({"layer": layer, "name": format!("r{k}")})).unwrap(); } // Each appends the next: r0 draws r1 to r3, and r3 is too deep to draw r4. for k in 0..4 { s.run("report.relatedReport", &json!({"report": format!("r{k}"), "related": format!("r{}", k + 1)})).unwrap(); } let mut read: Vec<_> = crate::cmd::report::reported_layers(&s, 0).into_iter().map(|(m, id)| s.project.maps[m].layer(id).unwrap().name.clone()).collect(); read.sort(); assert_eq!(read, ["Cities", "Countries", "Lakes", "Rivers"]); } /// A layer read in place a view at a time, its view holding one of its three points: its labels /// convert to an annotation for every point, read here or ahead as the app reads it, and a /// report's export lists every record (its selection kept by feature) while its preview says it /// shows those of the view. Rows the source can't give are said once; clean sources say nothing. #[test] fn labels_and_reports_of_a_layer_read_in_place_use_its_whole_source() { use rustgis_analysis::control::Control; use rustgis_map::report::Block; // Removed however the test ends. struct Dir(std::path::PathBuf); impl Drop for Dir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } // Its own each run (the process id alone can repeat). let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); let dir = Dir(std::env::temp_dir().join(format!("rustgis-whole-labels-{}-{nanos}", std::process::id()))); std::fs::create_dir_all(&dir.0).unwrap(); let dir = &dir.0; let point = |name: &str, x: f64, y: f64| json!({"type": "Feature", "properties": {"name": name}, "geometry": {"type": "Point", "coordinates": [x, y]}}); let bad = json!({"type": "Feature", "properties": {"name": "Bad"}, "geometry": {"type": "Point", "coordinates": "x"}}); // The notice for the one row of the layer its source can't give. let one_unread = crate::cmd::whole::unread_notice("places", 1); let unread = |s: &Session| s.notices.iter().filter(|n| n.text == one_unread).count(); let view = rustgis_geom::Extent::new(113.0, 22.0, 115.0, 24.0); let records = |doc: &rustgis_map::report::Doc| { doc.blocks .iter() .find_map(|b| match b { Block::Text(t) if t.contains(" records from ") => Some(t.clone()), _ => None, }) .unwrap() }; for (file, extra) in [("clean.geojson", None), ("bad.geojson", Some(bad))] { let mut features = vec![point("Lima", -77.0, -12.0), point("Oslo", 10.7, 59.9), point("Hong Kong", 114.17, 22.3)]; features.extend(extra.clone()); let path = dir.join(file); std::fs::write(&path, json!({"type": "FeatureCollection", "features": features}).to_string()).unwrap(); let said = usize::from(extra.is_some()); for ahead in [false, true] { let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "places"})).unwrap()["layer"] .as_u64() .unwrap() as u32; s.map_mut().sync_sources(&view, 1000.0, true); assert_eq!(s.map().layer(id).unwrap().dataset.features.len(), 1, "{file}: the view holds Hong Kong only"); let p = json!({"layer": id}); let r = if ahead { let read = crate::cmd::reads::read_ahead(&mut s, "labels.convert", &p).unwrap(); assert!(read.reads(), "{file}: read ahead"); crate::cmd::reads::read_land(&mut s, read.run(&Control::new()).unwrap()) } else { s.run("labels.convert", &p) } .unwrap_or_else(|e| panic!("{file}: {e}")); assert_eq!(r["annotations"], 3, "{file}, ahead {ahead}: {r}"); assert_eq!(unread(&s), said, "{file}, ahead {ahead}: labels.convert"); let l = s.map().layer(id).unwrap(); assert!(l.is_streamed() && !l.labels.enabled, "{file}: still read in place, its own labels off"); } // A report of it, then of only its selection (Hong Kong, the view's first row but not the // source's first). let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "places"})).unwrap()["layer"] .as_u64() .unwrap() as u32; s.map_mut().sync_sources(&view, 1000.0, true); s.run("report.new", &json!({"layer": "places"})).unwrap(); let preview = records(&crate::cmd::report::build(&s, 0).unwrap()); assert!(preview.starts_with("1 records") && preview.contains("only the records in the map's view"), "{preview}"); let mut copy = Session::new(s.project.clone()); crate::cmd::report::whole_copy(&mut copy, 0).unwrap(); let whole = records(&crate::cmd::report::build(©, 0).unwrap()); assert!(whole.starts_with("3 records") && !whole.contains("view"), "{whole}"); let before = unread(&s); let r = s.run("report.export", &json!({"path": dir.join("report.pdf").display().to_string(), "dpi": 72})).unwrap(); assert!(r["pages"].as_u64().unwrap() >= 1, "{r}"); assert_eq!(unread(&s), before + said, "{file}: report.export"); s.run("select.byAttributes", &json!({"layer": id, "where": "name = 'Hong Kong'"})).unwrap(); s.run("report.update", &json!({"selectedOnly": true})).unwrap(); let mut copy = Session::new(s.project.clone()); crate::cmd::report::whole_copy(&mut copy, 0).unwrap(); let doc = crate::cmd::report::build(©, 0).unwrap(); assert!(records(&doc).starts_with("1 records"), "{}", records(&doc)); let rows: Vec<_> = doc.blocks.iter().filter_map(|b| if let Block::Row(r) = b { Some(r.clone()) } else { None }).collect(); assert_eq!(rows.len(), 1, "{rows:?}"); assert!(rows[0].iter().any(|c| c == "Hong Kong"), "{rows:?}"); assert!(s.map().layer(id).unwrap().is_streamed()); } } /// Training read in place a view at a time: the view holds none of the training polygons, yet /// a model and a supervised classification learn from all of them, read from the source, and the /// rows the source can't give are said once. #[test] fn training_layers_read_in_place_train_from_their_whole_source() { // Removed however the test ends. struct Dir(std::path::PathBuf); impl Drop for Dir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } let dir = Dir(std::env::temp_dir().join(format!("rustgis-whole-training-{}", std::process::id()))); let _ = std::fs::remove_dir_all(&dir.0); std::fs::create_dir_all(&dir.0).unwrap(); let dir = &dir.0; let square = |class: &str, x: f64| json!({"type": "Feature", "properties": {"class": class}, "geometry": {"type": "Polygon", "coordinates": [[[x, 45.002], [x + 0.013, 45.002], [x + 0.013, 45.035], [x, 45.035], [x, 45.002]]]}}); let bad = json!({"type": "Feature", "properties": {"class": "field"}, "geometry": {"type": "Polygon", "coordinates": "x"}}); // The notice for the one row of the training layer its source can't give. let one_unread = crate::cmd::whole::unread_notice("training", 1); let unread = |s: &Session| s.notices.iter().filter(|n| n.text == one_unread).count(); for (file, extra) in [("clean.geojson", None), ("bad.geojson", Some(bad.clone()))] { let mut features = vec![square("water", 10.002), square("field", 10.025)]; features.extend(extra.clone()); let path = dir.join(file); std::fs::write(&path, json!({"type": "FeatureCollection", "features": features}).to_string()).unwrap(); let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "training"})).unwrap()["layer"] .as_u64() .unwrap() as u32; // The view is far from the polygons: the layer holds none of them. s.map_mut().sync_sources(&rustgis_geom::Extent::new(-61.0, -11.0, -59.0, -9.0), 1000.0, true); let l = s.map().layer(id).unwrap(); assert!(l.is_streamed() && l.dataset.features.is_empty(), "{file}: a window without the polygons"); let said = usize::from(extra.is_some()); let train = json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 5}); let r = s.run("dl.train", &train).unwrap_or_else(|e| panic!("{file}: {e}")); assert_eq!(r["classes"], json!(["water", "field"]), "{file}: {r}"); assert_eq!(unread(&s), said, "{file}: dl.train"); let r = s.run("dl.classify", &train).unwrap_or_else(|e| panic!("{file}: {e}")); assert_eq!(r["classes"].as_array().unwrap().len(), 2, "{file}: {r}"); assert_eq!(unread(&s), 2 * said, "{file}: dl.classify"); let r = s.run("raster.classifySupervised", &json!({"layer": "scene", "training": "training", "classField": "class"})).unwrap(); assert_eq!(r["classes"].as_array().unwrap().len(), 2, "{file}: {r}"); assert_eq!(unread(&s), 3 * said, "{file}: raster.classifySupervised"); // The live layer still reads its view: nothing was loaded into it. let l = s.map().layer(id).unwrap(); assert!(l.is_streamed() && l.dataset.features.is_empty(), "{file}"); } // A class whose only polygon can't be read: the error says the rows left out, its likely cause. let path = dir.join("lost-class.geojson"); std::fs::write(&path, json!({"type": "FeatureCollection", "features": [square("water", 10.002), bad]}).to_string()).unwrap(); let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "training"})).unwrap(); for command in ["dl.train", "raster.classifySupervised"] { let err = s.run(command, &json!({"layer": "scene", "training": "training", "classField": "class", "epochs": 5})).unwrap_err(); assert!(err.contains(&one_unread), "{command}: {err}"); } } #[test] fn deep_learning_hosted_imagery_and_sharing() { let dir = std::env::temp_dir().join(format!("rustgis-share-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = |f: &str| dir.join(f).to_str().unwrap().to_string(); let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); let training = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "field"}, "geometry": {"type": "Polygon", "coordinates": [[[10.025, 45.002], [10.038, 45.002], [10.038, 45.035], [10.025, 45.035], [10.025, 45.002]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "training.geojson", "text": training.to_string()})).unwrap(); assert!(s.run("dl.package", &json!({"path": path("x.rgisdlpk")})).is_err(), "no model yet"); // A class whose polygon misses the raster is named in the error. let lost = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "lost"}, "geometry": {"type": "Polygon", "coordinates": [[[-60.0, -10.0], [-59.0, -10.0], [-59.0, -9.0], [-60.0, -9.0], [-60.0, -10.0]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "lost.geojson", "text": lost.to_string()})).unwrap(); let err = s.run("dl.train", &json!({"layer": "scene", "training": "lost", "classField": "class"})).unwrap_err(); assert!(err.contains("'lost'"), "{err}"); let r = s .run( "dl.classify", &json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 30, "model": "landcover"}), ) .unwrap(); assert!(r["accuracy"].as_f64().unwrap() > 0.95, "{r}"); let classes = r["classes"].as_array().unwrap(); assert!(classes.iter().all(|c| c["pixels"].as_u64().unwrap() > 700), "both halves classified: {r}"); // Package, remove, import, apply again. s.run("dl.package", &json!({"path": path("landcover.rgisdlpk")})).unwrap(); s.run("dl.remove", &json!({"model": "landcover"})).unwrap(); assert!(s.project.dl_models.is_empty()); s.run("dl.import", &json!({"path": path("landcover.rgisdlpk")})).unwrap(); s.run("dl.classify", &json!({"layer": "scene", "model": "landcover", "name": "again"})).unwrap(); assert!(s.run("dl.import", &json!({"data": "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="})).is_err(), "an empty zip"); // Importing again doesn't replace the model; retraining it by name does, and says so. let r = s.run("dl.import", &json!({"path": path("landcover.rgisdlpk")})).unwrap(); assert_eq!(r["model"], "landcover 2"); let r = s .run( "dl.train", &json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 5, "model": "landcover"}), ) .unwrap(); assert_eq!(r["replaced"], true); assert_eq!(s.project.dl_models.len(), 2); // Hosted imagery: tiles in a zip, transparent outside the raster. let r = s.run("imagery.hostedImagery", &json!({"layer": "scene", "path": path("tiles.zip"), "minZoom": 10, "maxZoom": 12})).unwrap(); assert!(r["tiles"].as_u64().unwrap() >= 3); let entries = rustgis_data::zip::read(&std::fs::read(path("tiles.zip")).unwrap()).unwrap(); assert!(entries.iter().any(|e| e.0 == "tiles.json") && entries.iter().any(|e| e.0.starts_with("tiles/12/"))); // Sharing to a data store. assert!(s.run("share.webMap", &Value::Null).is_err(), "no path and no data store"); s.run("share.dataStore", &json!({"name": "out", "path": dir.to_str().unwrap()})).unwrap(); assert!(s.run("share.dataStore", &json!({"path": path("missing")})).is_err()); s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est"})).unwrap(); s.run("share.webMap", &Value::Null).unwrap(); let html = std::fs::read_to_string(path("World.html")).unwrap(); assert!( html.contains(" 5000000"})).unwrap(); let last_two = |s: &Session| s.notices.iter().rev().take(2).map(|n| n.text.clone()).collect::>(); s.run("share.webStyle", &json!({"layer": "Cities"})).unwrap(); let said = last_two(&s); assert!(said[0].contains("labels every feature with its Default class") && said[1].starts_with("Shared Cities"), "{said:?}"); s.run("labels.class.update", &json!({"layer": "Cities", "visible": false})).unwrap(); s.run("share.webStyle", &json!({"layer": "Cities"})).unwrap(); let said = last_two(&s); assert!(said[0].contains("has no labels") && said[1].starts_with("Shared Cities"), "{said:?}"); // A hidden Default alone is said too: the labels are on, and the style has none. s.run("labels.class.clear", &json!({"layer": "Cities"})).unwrap(); s.run("share.webStyle", &json!({"layer": "Cities"})).unwrap(); let said = last_two(&s); assert!(said[0].ends_with("its Default class is hidden.") && said[1].starts_with("Shared Cities"), "{said:?}"); s.run("share.locator", &json!({"layer": "Cities", "fields": ["name"]})).unwrap(); let r = s.run("locator.find", &json!({"text": "paris", "zoom": true})).unwrap(); assert_eq!(r["matches"][0]["text"], "Paris"); assert!((s.map().center_lonlat().x - 2.35).abs() < 0.1); s.run("share.geodataService", &Value::Null).unwrap(); s.run("model.new", &json!({"name": "Buffer", "steps": [{"tool": "buffer", "params": {"input": "Cities", "distance": 10}}]})).unwrap(); s.run("share.webTool", &Value::Null).unwrap(); s.run("model.import", &json!({"path": path("Buffer.rgistool")})).unwrap(); assert_eq!(s.project.models.len(), 2); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "height", "height": 100000})).unwrap(); s.run("share.webScene", &Value::Null).unwrap(); let g: Value = serde_json::from_slice(&std::fs::read(path("World.gltf")).unwrap()).unwrap(); assert_eq!(g["asset"]["version"], "2.0"); assert!(g["meshes"].as_array().unwrap().len() >= 2); assert!(s.run("share.linkChart", &Value::Null).is_err(), "no relationships yet"); s.run("data.relate", &json!({"name": "Country of city", "origin": "Countries", "originKey": "name", "destination": "Cities", "destinationKey": "adm0name"})).unwrap(); s.run("share.linkChart", &Value::Null).unwrap(); assert!(std::fs::read_to_string(path("Relationships.html")).unwrap().contains(" std::path::PathBuf { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut ds = Dataset::new(name, GeometryType::Point, vec![Field::new("i", FieldType::Integer), Field::new("x", FieldType::Double)]); for i in 0..n { for j in 0..n { let c = Coord::new(-179.0 + 358.0 * i as f64 / (n - 1) as f64, -80.0 + 160.0 * j as f64 / (n - 1) as f64); ds.push(Some(Geometry::Point(c)), vec![V::Int((i * n + j) as i64), V::Float(c.x)]); } } let p = std::env::temp_dir().join(format!("rustgis-engine-{}-{name}.fgb", std::process::id())); rustgis_data::io::write_path(&ds, &p).unwrap(); p } /// A view of a lon/lat box, 800 × 600 points. fn view_of(s: &mut Session, e: rustgis_geom::Extent) -> crate::render::View { s.view_size = (800.0, 600.0); s.zoom_to(&e); crate::render::View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false } } #[test] fn a_refused_edit_of_a_streamed_layer_says_why() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; let path = grid_fgb("refused", 20); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let streamed = r["layer"].as_u64().unwrap() as u32; // Its window read, as drawing does. let v = view_of(&mut s, rustgis_geom::Extent::new(-180.0, -80.0, 180.0, 80.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let count = |s: &Session| s.map().layer(streamed).unwrap().dataset.features.len(); let n = count(&s); assert!(n > 2, "{n}"); let mut d = Dataset::new("other", GeometryType::Point, vec![]); d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); let other = s.map_mut().add_dataset(d); // Refused up front (the streamed layer active), or undone after (another one active): either // way nothing changes, and the reason is said. for active in [streamed, other] { for (id, p) in [("edit.delete", json!(null)), ("edit.cut", json!(null)), ("edit.move", json!({"dx": 1000, "dy": 0}))] { s.active_layer = Some(active); s.map_mut().layer_mut(streamed).unwrap().selection = vec![0, 1]; let mark = s.notice_mark(); let e = s.run(id, &p).unwrap_err(); assert!(e.contains("read in place"), "{id}: {e}"); assert_eq!(count(&s), n, "{id}"); let said = s.notices_since(mark); assert!(said.iter().any(|x| x.error && x.text == e), "{id}: no notice"); // Nothing that says it was done ("Deleted 2 features"): it was undone. assert!(said.iter().all(|x| x.error), "{id}: {said:?}"); } } let _ = std::fs::remove_file(path); } #[test] fn large_sources_stream_a_view_at_a_time() { let path = grid_fgb("big", 150); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; // A small view limit, to see sampling without a huge file. s.map_mut().layer_mut(id).unwrap().source.as_mut().unwrap().limit = 5_000; let layer = |s: &Session| s.map().layer(id).unwrap().clone(); assert!(layer(&s).is_streamed()); // The whole extent, not the part read. assert!(layer(&s).extent().width() > 350.0); // The world: a spread sample, capped. let v = view_of(&mut s, rustgis_geom::Extent::new(-180.0, -80.0, 180.0, 80.0)); let png = crate::render::render_png(s.map_mut(), &v).unwrap(); assert!(!png.is_empty()); let l = layer(&s); let n = l.dataset.features.len(); assert!(n > 2_000 && n <= 5_000, "{n}"); assert!(l.source.as_ref().unwrap().sampled().is_some()); assert!(l.dataset.features.iter().any(|f| f.geometry.as_ref().unwrap().extent().min_x > 150.0)); // Zoomed in: exactly the features in view (padded), all of them. let small = rustgis_geom::Extent::new(10.0, 10.0, 14.0, 13.0); let v = view_of(&mut s, small); let lonlat = crate::render::view_lonlat(s.map(), &v); let scale = s.map().scale(); // Read in the background, as the screen does, then wait for it. let mut pending = s.map_mut().sync_sources(&lonlat, scale, false); let start = std::time::Instant::now(); while pending && start.elapsed() < std::time::Duration::from_secs(30) { std::thread::sleep(std::time::Duration::from_millis(5)); pending = s.map_mut().sync_sources(&lonlat, scale, false); } assert!(!pending); let l = layer(&s); assert!(l.source.as_ref().unwrap().sampled().is_none()); let w = l.source.as_ref().unwrap().window().unwrap().extent; let inside = |e: &rustgis_geom::Extent| l.dataset.features.iter().filter(|f| e.contains(f.geometry.as_ref().unwrap().extent().center())).count(); assert_eq!(inside(&w), l.dataset.features.len()); assert!(inside(&lonlat) > 0); // A selection survives the next read (by feature id). let ids: Vec = l.dataset.features.iter().take(3).map(|f| f.id).collect(); s.map_mut().layer_mut(id).unwrap().selection = vec![0, 1, 2]; let v = view_of(&mut s, rustgis_geom::Extent::new(-40.0, -40.0, 60.0, 60.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let l = layer(&s); let kept: Vec = l.selection.iter().map(|r| l.dataset.features[*r].id).collect(); assert!(kept.iter().all(|k| ids.contains(k)) && !kept.is_empty(), "{kept:?}"); // Edits are refused; Load All Features brings them into memory. s.run("edit.enable", &json!({"on": true})).unwrap(); s.active_layer = Some(id); let e = s.run("edit.delete", &json!({"layer": id})).unwrap_err(); assert!(e.contains("read in place"), "{e}"); let r = s.run("layer.loadAll", &json!({"layer": id})).unwrap(); assert_eq!(r["features"], 22_500); let l = layer(&s); assert!(!l.is_streamed() && !l.dataset.windowed); s.map_mut().layer_mut(id).unwrap().selection = vec![0]; s.run("edit.delete", &json!({"layer": id})).unwrap(); assert_eq!(layer(&s).dataset.features.len(), 22_499); let _ = std::fs::remove_file(path); } #[test] fn streamed_layers_save_their_source_not_their_features() { let path = grid_fgb("saved", 60); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let v = view_of(&mut s, rustgis_geom::Extent::new(-180.0, -80.0, 180.0, 80.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert_eq!(s.map().layers[0].dataset.features.len(), 3600); let text = serde_json::to_string(&s.project).unwrap(); // The window isn't in the project (3,600 features would be far more than this). assert!(text.len() < 20_000, "{}", text.len()); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = &t.map().layers[0]; assert!(l.is_streamed() && l.source.as_ref().unwrap().is_open()); assert!(l.dataset.features.is_empty()); let v = view_of(&mut t, rustgis_geom::Extent::new(-180.0, -80.0, 180.0, 80.0)); crate::render::render_png(t.map_mut(), &v).unwrap(); assert_eq!(t.map().layers[0].dataset.features.len(), 3600); // A missing file is reported, not a crash. // (The path as the project's JSON spells it: escaped backslashes on Windows.) let quoted = serde_json::to_string(&std::path::absolute(&path).unwrap().display().to_string()).unwrap(); assert!(text.contains(quoted.trim_matches('"')), "{text}"); let gone = text.replace(quoted.trim_matches('"'), "/nonexistent/gone.fgb"); let mut u = Session::new(Project::default()); u.run("project.openText", &json!({"text": gone})).unwrap(); assert!(u.map().layers[0].source.as_ref().unwrap().error.is_some()); crate::render::render_png(u.map_mut(), &v).unwrap(); let _ = std::fs::remove_file(path); } #[test] fn tools_read_streamed_layers_whole() { let path = grid_fgb("tools", 50); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); // Only a corner is in view. let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let windowed = s.map().layers[0].dataset.features.len(); assert!(windowed < 2500, "{windowed}"); let r = s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "tools", "distance": 1}})).unwrap(); let out = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(out.dataset.features.len(), 2500); // A tool updating the layer from a selection (made in the window) would replace the whole // layer with that part: refused. Without a selection it updates the whole source. let id = s.map().layer_by_name("tools").unwrap().id; s.map_mut().layer_mut(id).unwrap().selection = vec![0, 1]; let e = s.run("gp.run", &json!({"tool": "calculateGeometry", "params": {"input": "tools"}})).unwrap_err(); assert!(e.contains("Load All Features"), "{e}"); assert!(s.map().layer(id).unwrap().is_streamed()); s.map_mut().layer_mut(id).unwrap().selection.clear(); s.run("gp.run", &json!({"tool": "calculateGeometry", "params": {"input": "tools"}})).unwrap(); assert_eq!(s.map().layer(id).unwrap().dataset.features.len(), 2500); let _ = std::fs::remove_file(path); } #[test] fn pmtiles_exports_selected_streamed_features_by_id_and_honors_extent_without_selection() { use crate::render::rustgis_tiles::mvt; use std::collections::BTreeSet; let path = grid_fgb("pmtiles-streamed-selection", 20); let mut s = Session::new(Project::default()); s.queue_gp_files = true; s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let view = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 40.0, 40.0)); crate::render::render_png(s.map_mut(), &view).unwrap(); let layer = &s.map().layers[0]; assert!(layer.dataset.windowed); assert!(layer.dataset.features.len() >= 2 && layer.dataset.features.len() < 400); let id = layer.id; let name = layer.name.clone(); let selected = vec![layer.dataset.features.len() - 1, 0]; let expected: BTreeSet<_> = selected .iter() .map(|row| { let f = &layer.dataset.features[*row]; (f.id, f.attrs[0].as_f64().unwrap() as i64) }) .collect(); let full = layer.load_all().unwrap(); assert!(selected.iter().any(|row| full.features[*row].id != layer.dataset.features[*row].id)); s.map_mut().layer_mut(id).unwrap().selection = selected; // Selection takes precedence over the environment, even when it is outside the extent. let extent = rustgis_geom::Extent::new(-150.0, -70.0, -100.0, -30.0); s.project.gp_env.extent = Some(extent); let export = |s: &mut Session| -> BTreeSet<(u64, i64)> { s.run("gp.run", &json!({"tool": "vectorToPmtiles", "params": {"input": name, "minZoom": 0, "maxZoom": 0}})).unwrap(); let files = s.take_gp_files(); assert_eq!(files.len(), 1); let bytes = &files[0].2; let offset = u64::from_le_bytes(bytes[56..64].try_into().unwrap()) as usize; let len = u64::from_le_bytes(bytes[64..72].try_into().unwrap()) as usize; let tile = mvt::decode(&bytes[offset..offset + len]).unwrap(); assert_eq!(tile.layers.len(), 1); tile.layers[0] .features .iter() .map(|f| { let Some(mvt::Value::Number(value)) = f.prop("i") else { panic!("missing source attribute") }; (f.id.unwrap(), *value as i64) }) .collect() }; assert_eq!(export(&mut s), expected, "no unselected rows may leak into the archive"); assert!(s.map().layer(id).unwrap().is_streamed()); assert!(s.map().layer(id).unwrap().dataset.windowed); s.map_mut().layer_mut(id).unwrap().selection = vec![usize::MAX]; let error = s.run("gp.run", &json!({"tool": "vectorToPmtiles", "params": {"input": name, "minZoom": 0, "maxZoom": 0}})).unwrap_err(); assert!(error.contains("a selected feature is no longer in the source"), "{error}"); assert!(s.take_gp_files().is_empty(), "an invalid selection must not become an all-features export"); s.map_mut().layer_mut(id).unwrap().selection.clear(); let inside: BTreeSet<_> = full .features .iter() .filter(|f| f.geometry.as_ref().is_some_and(|g| g.extent().intersects(&extent))) .map(|f| (f.id, f.attrs[0].as_f64().unwrap() as i64)) .collect(); assert!(!inside.is_empty() && inside.len() < 400); assert_eq!(export(&mut s), inside, "an unselected streamed export uses the full source's extent rows"); s.project.gp_env.extent = Some(rustgis_geom::Extent::new(179.5, 85.0, 180.0, 86.0)); assert!(s.run("gp.run", &json!({"tool": "vectorToPmtiles", "params": {"input": name, "minZoom": 0, "maxZoom": 0}})).is_err()); assert!(s.take_gp_files().is_empty(), "an empty extent must not fall back to exporting every feature"); s.project.gp_env.extent = None; assert_eq!(export(&mut s).len(), 400, "an unselected export without an extent reads every source row"); std::fs::remove_file(path).unwrap(); } #[test] fn web_style_carries_the_layers_scale_limits_as_zooms() { let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let zooms = |s: &Session| { let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap()); style["layers"].as_array().unwrap().iter().map(|l| (l.get("minzoom").cloned(), l.get("maxzoom").cloned())).collect::>() }; // No limits, no keys. assert!(zooms(&s).iter().all(|(a, b)| a.is_none() && b.is_none())); let c = s.map().layer(id).unwrap().extent().center(); // Ground metres per pixel at the layer's latitude, and the MapLibre zoom (512-pixel world). let zoom_of = |scale: f64| (2.0 * std::f64::consts::PI * 6_378_137.0 * c.y.to_radians().cos() / (512.0 * scale * 0.00028)).log2(); let l = s.map_mut().layer_mut(id).unwrap(); l.min_scale = Some(5_000_000.0); l.max_scale = Some(100_000.0); l.labels.enabled = true; l.labels.default.field = "name".into(); let z = zooms(&s); // The label layer is limited like the symbols. assert!(z.len() >= 2, "{z:?}"); for (min, max) in z { assert!((min.as_ref().unwrap().as_f64().unwrap() - zoom_of(5_000_000.0)).abs() < 1e-3, "{min:?}"); assert!((max.as_ref().unwrap().as_f64().unwrap() - zoom_of(100_000.0)).abs() < 1e-3, "{max:?}"); } // One limit gives one key; nonsense gives none. let l = s.map_mut().layer_mut(id).unwrap(); l.max_scale = Some(f64::NAN); l.min_scale = Some(-1.0); assert!(zooms(&s).iter().all(|(a, b)| a.is_none() && b.is_none())); s.map_mut().layer_mut(id).unwrap().max_scale = Some(100_000.0); assert!(zooms(&s).iter().all(|(a, b)| a.is_none() && b.is_some())); // A hidden Default class labels nothing on the map, nor in the style. let labelled = |s: &Session| { let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap()); style["layers"].as_array().unwrap().iter().any(|l| l["type"] == "symbol") }; assert!(labelled(&s)); // The halo is the one the map draws: without its own colour, light behind dark text and dark // behind light; its own colour as it is, see-through included. let halo = |s: &Session| { let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap()); let label = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "symbol").cloned().unwrap(); label["paint"]["text-halo-color"].as_str().unwrap().to_string() }; let l = s.map_mut().layer_mut(id).unwrap(); l.labels.default.color = rustgis_map::Rgba::rgb(0, 0, 0); l.labels.default.halo_color = None; assert_eq!(halo(&s), "rgba(255,255,255,0.882)"); s.map_mut().layer_mut(id).unwrap().labels.default.color = rustgis_map::Rgba::rgb(255, 255, 255); assert_eq!(halo(&s), "rgba(20,22,26,0.824)"); s.map_mut().layer_mut(id).unwrap().labels.default.halo_color = Some(rustgis_map::Rgba::new(255, 0, 0, 128)); assert_eq!(halo(&s), "rgba(255,0,0,0.502)"); // An offset in ems, as MapLibre has it; none without one. let offset = |s: &Session| { let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap()); let label = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "symbol").cloned().unwrap(); label["layout"].get("text-offset").cloned() }; assert_eq!(offset(&s), None); let c = &mut s.map_mut().layer_mut(id).unwrap().labels.default; (c.size, c.offset) = (10.0, [5.0, -2.5]); assert_eq!(offset(&s), Some(json!([0.5, -0.25]))); s.map_mut().layer_mut(id).unwrap().labels.default.visible = false; assert!(!labelled(&s)); } #[test] fn web_shares_carry_every_feature_of_a_streamed_layer() { let path = grid_fgb("webshare", 50); let dir = std::env::temp_dir().join(format!("rustgis-webshare-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); // Only a corner is in view. let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert!(s.map().layers[0].dataset.features.len() < 2500); let count = |zip: &std::path::Path, name: &str| -> usize { let z = rustgis_data::zip::read(&std::fs::read(zip).unwrap()).unwrap(); let items = z.iter().find(|e| e.0 == name).unwrap(); let v: Value = serde_json::from_slice(&items.1).unwrap(); v["features"].as_array().unwrap().len() }; let layer_zip = dir.join("layer.zip"); s.run("share.webLayer", &json!({"layer": "webshare", "path": layer_zip.display().to_string()})).unwrap(); assert_eq!(count(&layer_zip, "collections/webshare/items.json"), 2500); let service_zip = dir.join("service.zip"); s.run("share.geodataService", &json!({"path": service_zip.display().to_string()})).unwrap(); assert_eq!(count(&service_zip, "collections/webshare/items.json"), 2500); // A scene from the corner view matches one made with every feature in hand. let scene = dir.join("scene.gltf"); s.run("share.webScene", &json!({"path": scene.display().to_string()})).unwrap(); let windowed = std::fs::read(&scene).unwrap(); let all = s.map().layers[0].load_all().unwrap(); s.map_mut().layers[0].dataset = all; s.run("share.webScene", &json!({"path": scene.display().to_string()})).unwrap(); assert!(windowed == std::fs::read(&scene).unwrap(), "the scene depends on the view"); let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_file(path); } /// MapLibre map links: read with their bearing and pitch (or without), written back the same, /// and junk refused without moving the map. #[test] fn map_links() { let mut s = world(); let r = s.run("map.goToLink", &json!({"url": "https://example.com/map/#15.5/40.7484/-73.9857/30/60"})).unwrap(); assert_eq!((r["bearing"].as_f64(), r["pitch"].as_f64()), (Some(30.0), Some(60.0))); let c = s.map().center_lonlat(); assert!((c.x + 73.9857).abs() < 1e-6 && (c.y - 40.7484).abs() < 1e-6, "{c:?}"); assert_eq!(s.run("map.link", &Value::Null).unwrap()["hash"], "#15.5/40.7484/-73.9857/30/60"); // MapLibre's zoom 0 shows the world's 40,075 km across 512 pixels. s.run("map.goToLink", &json!({"url": "0/0/0"})).unwrap(); let ground = s.map().camera.res * s.map().flat().ground_per_unit(s.map().camera.center); assert!((ground - 2.0 * std::f64::consts::PI * 6_378_137.0 / 512.0).abs() < 1.0, "{ground}"); assert_eq!((s.map().camera.bearing, s.map().camera.pitch), (0.0, 0.0), "left out, they are zero"); // A named hash among other parameters, and bearing without pitch. s.run("map.goToLink", &json!({"url": "https://example.com/#layers=a&map=4/10/20/-45"})).unwrap(); assert_eq!(s.map().camera.bearing, -45.0); assert_eq!(s.run("map.link", &Value::Null).unwrap()["hash"], "#4/10/20/-45"); // MapLibre's own `map=` before other parameters that look like positions. s.run("map.goToLink", &json!({"url": "#other=1/2/3&map=4/10/20"})).unwrap(); assert_eq!(s.run("map.link", &Value::Null).unwrap()["hash"], "#4/10/20"); // A view wider than zoom 0 gives a link that reads back; no "-0". s.map_mut().camera.res = rustgis_map::camera::MAX_RES; s.map_mut().camera.center = rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(-1e-9, 0.0)); let hash = s.run("map.link", &Value::Null).unwrap()["hash"].as_str().unwrap().to_string(); assert_eq!(hash, "#0/0/0"); s.run("map.goToLink", &json!({"url": hash})).unwrap(); assert!(crate::cmd::navigate::is_map_link("#map=4/10/20") && !crate::cmd::navigate::is_map_link("#about")); let before = s.map().camera; for bad in [ json!({"url": "https://example.com/#about"}), json!({"url": "https://example.com/?a=1/2/3"}), json!({"url": "map=1/2/3"}), json!({"url": "#4/95/0"}), json!({"url": "#4/0/200"}), json!({"url": "#99/0/0"}), json!({"url": "#4/0"}), json!({"url": "#4/0/0/1/2/3"}), json!({"url": "#NaN/0/0"}), json!({"url": "#inf/0/0"}), json!({"url": "#4/0/0/1e400"}), json!({"url": "/".repeat(100_000)}), json!({"url": format!("#4/0/0{}", "é".repeat(100_000))}), json!({"url": format!("{}#4/0/0", "€".repeat(2000))}), json!({"url": 3}), json!({}), ] { assert!(s.run("map.goToLink", &bad).is_err(), "{bad}"); } assert_eq!(s.map().camera, before); } /// A MapLibre style added with `"view": true` takes the map to its root center, zoom, bearing /// and pitch once it has loaded; without it the map stays where it is. #[test] fn style_root_view_moves_the_map() { if std::env::var_os("RUSTGIS_TEST_STYLE_VIEW").is_none() { // Parallel reload tests clear the process-wide tile cache. Isolate these downloads: // refetching after a reload can exhaust the fixture server's request allowance. let output = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "tests::style_root_view_moves_the_map", "--nocapture"]) .env("RUSTGIS_TEST_STYLE_VIEW", "1") .output() .unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); assert!(stdout.contains("1 passed; 0 failed"), "the child must run the style-view test: {stdout}"); return; } const STYLE: &[u8] = br#"{"version": 8, "center": [2.2945, 48.8584], "zoom": 16, "bearing": -20, "pitch": 55, "sources": {}, "layers": [{"id": "bg", "type": "background"}]}"#; let base = serve(STYLE, 5); let mut s = world(); let before = s.map().camera; s.run("map.addTiles", &json!({"url": format!("{base}/plain/style.json")})).unwrap(); assert!(s.style_view.is_none()); assert_eq!(s.map().camera, before); s.run("map.addTiles", &json!({"url": format!("{base}/view/style.json"), "view": true})).unwrap(); // Loaded in the background; followed once it is. let start = std::time::Instant::now(); while s.style_view.is_some() && start.elapsed() < std::time::Duration::from_secs(20) { crate::cmd::tiles::follow_style_view(&mut s, web_time::Duration::from_secs(5)); } assert!(s.style_view.is_none()); let m = s.map(); let c = m.center_lonlat(); assert!((c.x - 2.2945).abs() < 1e-6 && (c.y - 48.8584).abs() < 1e-6, "{c:?}; notices: {:?}", s.notices); assert_eq!((m.camera.bearing, m.camera.pitch), (-20.0, 55.0)); assert_eq!(s.run("map.link", &Value::Null).unwrap()["hash"], "#16/48.8584/2.2945/-20/55"); // Only a style has a view of its own. let e = s.run("map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "view": true})).unwrap_err(); assert!(e.contains("style"), "{e}"); // A style that can't load is said, and forgotten. s.run("map.addTiles", &json!({"url": format!("{base}/missing/style.json"), "view": true})).unwrap(); let start = std::time::Instant::now(); while s.style_view.is_some() && start.elapsed() < std::time::Duration::from_secs(20) { crate::cmd::tiles::follow_style_view(&mut s, web_time::Duration::from_secs(5)); } assert!(s.style_view.is_none()); assert!(s.notices.iter().any(|n| n.error && n.text.contains("missing")), "{:?}", s.notices); // Moved while the style loads: the map stays where it was put. s.run("map.addTiles", &json!({"url": format!("{base}/moved/style.json"), "view": true})).unwrap(); assert!(s.style_view.is_some()); let panned = rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(10.0, 20.0)); s.map_mut().camera.center = panned; crate::cmd::tiles::follow_style_view(&mut s, web_time::Duration::from_secs(5)); assert!(s.style_view.is_none()); assert_eq!(s.map().camera.center, panned); // A style still loading when another project opens doesn't move that one. s.run("map.addTiles", &json!({"url": "https://tiles.example.invalid/style.json", "view": true})).unwrap(); assert!(s.style_view.is_some()); s.run("project.new", &Value::Null).unwrap(); assert!(s.style_view.is_none()); } /// A web scene carries a camera at the map's viewpoint: straight down when the map is flat, and /// from its bearing and pitch when it is turned and tilted. #[test] fn web_scene_camera() { let dir = std::env::temp_dir().join(format!("rustgis_scene_camera_{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("scene.gltf").display().to_string(); let mut s = world(); s.view_size = (800.0, 600.0); let scene = |s: &mut Session| -> Value { s.run("share.webScene", &json!({"path": path})).unwrap(); serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap() }; // Where a node's camera looks (-z turned by its rotation) and its up (+y). let axes = |n: &Value| { let q: Vec = n["rotation"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); let (x, y, z, w) = (q[0], q[1], q[2], q[3]); let turn = |v: [f64; 3]| { let t = [2.0 * (y * v[2] - z * v[1]), 2.0 * (z * v[0] - x * v[2]), 2.0 * (x * v[1] - y * v[0])]; [v[0] + w * t[0] + (y * t[2] - z * t[1]), v[1] + w * t[1] + (z * t[0] - x * t[2]), v[2] + w * t[2] + (x * t[1] - y * t[0])] }; (turn([0.0, 0.0, -1.0]), turn([0.0, 1.0, 0.0])) }; let near = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-9); let g = scene(&mut s); let cam = g["nodes"].as_array().unwrap().iter().find(|n| n.get("camera").is_some()).unwrap().clone(); assert_eq!(g["cameras"][0]["type"], "perspective"); assert!((g["cameras"][0]["perspective"]["aspectRatio"].as_f64().unwrap() - 800.0 / 600.0).abs() < 1e-9); let (look, up) = axes(&cam); assert!(near(look, [0.0, -1.0, 0.0]) && near(up, [0.0, 0.0, -1.0]), "down, north up: {look:?} {up:?}"); let t: Vec = cam["translation"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); // As far up as the map's scale needs: 300 points of ground under half the field of view. let ground = s.map().camera.res * s.map().flat().ground_per_unit(s.map().camera.center); assert!(t[0].abs() < 1e-6 && t[2].abs() < 1e-6 && (t[1] - 300.0 * 3.0 * ground).abs() < 1e-3 * t[1], "{t:?}"); // Facing east, tilted 60°: behind the centre to the west, looking east and down. s.run("map.setBearing", &json!({"bearing": 90})).unwrap(); s.run("map.setPitch", &json!({"pitch": 60})).unwrap(); let g = scene(&mut s); assert_eq!((g["extras"]["bearing"].as_f64(), g["extras"]["pitch"].as_f64()), (Some(90.0), Some(60.0))); let cam = g["nodes"].as_array().unwrap().iter().find(|n| n.get("camera").is_some()).unwrap().clone(); let (look, _) = axes(&cam); let (s60, c60) = (60f64.to_radians().sin(), 60f64.to_radians().cos()); assert!(near(look, [s60, -c60, 0.0]), "{look:?}"); let t: Vec = cam["translation"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert!(t[0] < 0.0 && t[1] > 0.0 && t[2].abs() < 1e-6 * t[1], "{t:?}"); // The camera looks at the centre (the origin). let d = (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt(); assert!(near([-t[0] / d, -t[1] / d, -t[2] / d], look), "{t:?} {look:?}"); let _ = std::fs::remove_dir_all(dir); } #[test] fn reloading_web_layers_clears_the_tile_cache() { use crate::render::rustgis_tiles::cache; let mut s = world(); let before = cache::generation(); let r = s.run("map.reloadWebLayers", &Value::Null).unwrap(); // Other tests share the cache, so the generation may have moved further. assert!(r["generation"].as_u64().unwrap() > before); // Parameters are ignored. s.run("map.reloadWebLayers", &json!({"junk": [1, null, "x"]})).unwrap(); } #[test] fn a_streamed_layer_whose_file_goes_away_shows_an_error_not_stale_features() { let path = grid_fgb("vanishing", 40); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 30.0, 30.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert!(!s.map().layers[0].dataset.features.is_empty()); std::fs::remove_file(&path).unwrap(); let v = view_of(&mut s, rustgis_geom::Extent::new(-150.0, -60.0, -120.0, -30.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let l = &s.map().layers[0]; assert!(l.dataset.features.is_empty()); assert!(l.source.as_ref().unwrap().error.is_some()); } /// `layout.publication` over a streamed layer: the frame's view is read for its legend, which lists /// the layer; undone, the layout goes and the layer is streaming from its source as before, /// reading a view again when one is drawn. #[test] fn a_publication_layout_over_a_streamed_layer_is_undone() { let path = grid_fgb("publication", 30); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let r = s.run("layout.publication", &json!({"extent": [0, 0, 20, 20], "author": ""})).unwrap(); assert!(s.project.layouts[0].elements.iter().any(|e| e.name == "Legend"), "the read layer is listed: {r}"); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.project.layouts.is_empty()); let l = &s.map().layers[0]; assert!(l.source.as_ref().is_some_and(|src| src.error.is_none()), "still streaming from its source"); let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert!(!s.map().layers[0].dataset.features.is_empty(), "it reads the view again"); let _ = std::fs::remove_file(path); } #[test] fn any_command_editing_a_streamed_layer_is_undone() { let path = grid_fgb("guarded", 30); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); // A sample of the world, so a closer view reads again. s.map_mut().layers[0].source.as_mut().unwrap().limit = 300; let v = view_of(&mut s, rustgis_geom::Extent::new(-180.0, -80.0, 180.0, 80.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let before = s.map().layers[0].dataset.features.clone(); // Whatever the command, an edit of the window is undone and refused. let r = s.guarded("Some Edit", true, |s| { if let Some(f) = s.map_mut().layers[0].dataset.features.get_mut(0) { f.attrs[0] = rustgis_data::Value::Int(-1); } Ok(Value::Null) }); assert!(r.unwrap_err().contains("Load All Features")); assert_eq!(s.map().layers[0].dataset.features, before); // A registered non-edit command must not launder row edits by reopening its handle. s.registry.commands.push(crate::CommandSpec::new("test.reopenAndEdit", "Reopen and edit", "Test", |s, _| { let l = &mut s.map_mut().layers[0]; let reads = l.source.as_ref().unwrap().reads; let mut reopened = l.source.as_ref().unwrap().clone(); reopened.reopen()?; reopened.reads = reads; l.source = Some(reopened); l.dataset.features[0].attrs[0] = rustgis_data::Value::Int(-1); Ok(Value::Null) })); let source = s.map().layers[0].source.clone().unwrap(); let depth = s.undo_depth(); let e = s.run("test.reopenAndEdit", &Value::Null).unwrap_err(); assert!(e.contains("Load All Features"), "{e}"); assert_eq!(s.map().layers[0].dataset.features, before); assert!(s.map().layers[0].source.as_ref().unwrap().same_opening(&source)); assert_eq!(s.undo_depth(), depth); s.run("layer.reloadData", &Value::Null).unwrap(); assert_eq!(s.map().layers[0].source.as_ref().unwrap().reads, source.reads + 1); crate::render::render_png(s.map_mut(), &v).unwrap(); // A command whose render reads the source again is fine. let r = s.guarded("Render", true, |s| { let v = view_of(s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).map(|_| Value::Null) }); assert!(r.is_ok(), "{r:?}"); assert_ne!(s.map().layers[0].dataset.features.len(), before.len()); let _ = std::fs::remove_file(path); } /// A GDAL-only format (GeoJSON sequence), when GDAL is installed (skipped otherwise). #[cfg(not(target_arch = "wasm32"))] #[test] fn gdal_formats_open_when_gdal_is_installed() { if !rustgis_data::source::gdal::available() { return; } let dir = std::env::temp_dir().join(format!("rustgis-engine-gdal-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let seq = dir.join("walk.geojsonl"); std::fs::write( &seq, "{\"type\":\"Feature\",\"properties\":{\"name\":\"start\"},\"geometry\":{\"type\":\"Point\",\"coordinates\":[7,45]}}\n{\"type\":\"Feature\",\"properties\":{\"name\":\"end\"},\"geometry\":{\"type\":\"Point\",\"coordinates\":[7.1,45.1]}}\n", ) .unwrap(); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": seq.display().to_string()})).unwrap(); let l = s.map().layers.iter().find(|l| l.dataset.features.len() == 2).unwrap_or_else(|| panic!("{r}")); assert_eq!(l.dataset.source.as_deref(), Some(std::path::absolute(&seq).unwrap().display().to_string().as_str())); // Streamed, the layer remembers the original file. let r = s.run("layer.addData", &json!({"path": seq.display().to_string(), "stream": true})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().source.as_ref().unwrap().gdal.is_some()); let _ = std::fs::remove_dir_all(dir); } /// KML (with its styles), KMZ and GPX through the built-in readers: files on disk and uploads. #[test] fn kml_and_gpx_add_as_layers() { let dir = std::env::temp_dir().join(format!("rustgis-engine-kml-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let kml = r##" a#red7,45 8,46 b#blue7,46 8,45 p7.5,45.5 "##; let path = dir.join("roads.kml"); std::fs::write(&path, kml).unwrap(); let mut s = Session::new(Project::default()); let listed = s.run("info.fileLayers", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(listed["layers"], json!(["roads points", "roads lines"])); let r = s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(2), "{r}"); let lines = s.map().layer_by_name("roads lines").unwrap(); match &lines.renderer { rustgis_map::Renderer::Unique { field, classes, .. } => { assert_eq!(field, "style"); let red = classes.iter().find(|(k, _)| k == "red").unwrap().1; assert_eq!((red.stroke, red.stroke_width), (rustgis_map::Rgba::new(255, 0, 0, 255), 4.0)); } other => panic!("{other:?}"), } // One layer by name. let one = s.run("layer.addData", &json!({"path": path.display().to_string(), "layer": "lines"})).unwrap(); assert_eq!(one["layers"].as_array().map(Vec::len), Some(1)); assert!(s.run("layer.addData", &json!({"path": path.display().to_string(), "layer": "nope"})).is_err()); // A KMZ upload (the web app). { use base64::Engine; let kmz = rustgis_data::zip::write(&[("doc.kml".to_string(), kml.as_bytes().to_vec())]).unwrap(); let data = base64::engine::general_purpose::STANDARD.encode(kmz); let r = s.run("layer.addDataBytes", &json!({"fileName": "roads.kmz", "data": data})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(2), "{r}"); } // GPX: waypoints, tracks and track points. let gpx = dir.join("walk.gpx"); std::fs::write( &gpx, r#" startend walk"#, ) .unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": gpx.display().to_string()})).unwrap(); let names: Vec = s.map().layers.iter().map(|l| l.name.clone()).collect(); for n in ["walk waypoints", "walk tracks", "walk track points"] { assert!(names.contains(&n.to_string()), "{names:?}"); } assert_eq!(s.map().layer_by_name("walk waypoints").unwrap().dataset.features.len(), 2); let project = dir.join("p.rgis").display().to_string(); s.run("project.saveAs", &json!({"path": project})).unwrap(); s.run("project.open", &json!({"path": project})).unwrap(); assert_eq!(s.map().layers.len(), 3); let _ = std::fs::remove_dir_all(dir); } /// SQL queries and DuckDB databases through DuckDB, when it is installed; without it, both say /// how to get it. #[cfg(not(target_arch = "wasm32"))] #[test] fn duckdb_queries_and_databases() { let mut s = Session::new(Project::default()); let query = "SELECT 'a' AS name, ST_Point(7, 45) AS geom UNION ALL SELECT 'b', ST_GeomFromText('LINESTRING(7 45, 8 46)')"; if !rustgis_data::source::duckdb::available() { let e = s.run("layer.addSql", &json!({"sql": query})).unwrap_err(); assert!(e.contains("need DuckDB"), "{e}"); let e = s.run("layer.addData", &json!({"path": "/nowhere/x.duckdb"})).unwrap_err(); assert!(e.contains("need DuckDB"), "{e}"); return; } // A result mixing geometry types: a layer per type, its points on top (#1488). let r = s.run("layer.addSql", &json!({"sql": query, "name": "Trip"})).unwrap(); let names: Vec = s.map().layers.iter().map(|l| l.name.clone()).collect(); assert_eq!(names, vec!["Trip (points)", "Trip (lines)"], "{r}"); let point = s.map().layer_by_name("Trip (points)").unwrap(); assert_eq!(point.dataset.features[0].geometry, Some(rustgis_data::Geometry::Point(rustgis_data::Coord::new(7.0, 45.0)))); let line = s.map().layer_by_name("Trip (lines)").unwrap(); assert_eq!(line.dataset.geometry_type, rustgis_data::GeometryType::Polyline); assert_eq!(line.dataset.features[0].geometry.as_ref().unwrap().extent().center(), rustgis_data::Coord::new(7.5, 45.5)); // Streamed, a layer remembers its query; a project never runs it again when the result is gone // (a project file is hostile input), and says how to. let r = s .run( "layer.addSql", &json!({"sql": "SELECT i AS id, ST_Point(i, i / 2) AS geom FROM range(50) t(i)", "name": "Pts", "stream": true}), ) .unwrap(); assert_eq!(r["features"], 50); let id = r["layer"].as_u64().unwrap() as u32; let src = s.map().layer(id).unwrap().source.clone().unwrap(); let d = src.duckdb.clone().unwrap(); assert!(d.sql.as_deref().is_some_and(|q| q.contains("range(50)")) && d.family == rustgis_data::source::Family::Points); let cached = src.path.clone().unwrap(); let text = serde_json::to_string(&s.project).unwrap(); std::fs::remove_file(&cached).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = t.map().layer_by_name("Pts").unwrap(); let again = l.source.as_ref().unwrap(); assert!(!again.is_open()); assert!(again.error.as_deref().is_some_and(|e| e.contains("run the query again")), "{:?}", again.error); // Errors are DuckDB's, and nothing is added. let before = s.map().layers.len(); assert!(s.run("layer.addSql", &json!({"sql": "SELECT 1 AS a"})).unwrap_err().contains("no geometry column")); assert!(s.run("layer.addSql", &json!({"sql": "SELECT * FROM nowhere"})).unwrap_err().contains("nowhere")); assert_eq!(s.map().layers.len(), before); // A database file: its geometry tables become layers, or the one named. let dir = std::env::temp_dir().join(format!("rustgis-engine-duckdb-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("places.duckdb"); let ok = std::process::Command::new(rustgis_data::source::duckdb::program().unwrap()) .arg(&db) .arg("-c") .arg("LOAD spatial; CREATE TABLE towns AS SELECT 'x' AS name, ST_Point(2.35, 48.85) AS geom; CREATE TABLE lakes AS SELECT ST_GeomFromText('POLYGON((6 46, 7 46, 7 47, 6 46))') AS geom; CREATE TABLE notes(t TEXT);") .status() .unwrap() .success(); assert!(ok); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": db.display().to_string()})).unwrap(); let mut names: Vec = s.map().layers.iter().map(|l| l.name.clone()).collect(); names.sort(); assert_eq!(names, vec!["lakes", "towns"], "{r}"); assert_eq!( s.map().layer_by_name("towns").unwrap().dataset.source.as_deref(), Some(std::path::absolute(&db).unwrap().display().to_string().as_str()) ); let r = s.run("layer.addData", &json!({"path": db.display().to_string(), "layer": "towns", "stream": true, "name": "T"})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; let src = s.map().layer(id).unwrap().source.clone().unwrap(); assert_eq!(src.duckdb.as_ref().unwrap().table.as_deref(), Some("towns")); // A table is converted again when its result is gone. let text = serde_json::to_string(&s.project).unwrap(); std::fs::remove_file(src.path.as_deref().unwrap()).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("T").unwrap().source.as_ref().unwrap(); assert!(again.is_open(), "{:?}", again.error); assert_eq!(again.info().and_then(|i| i.count), Some(1)); assert!(s.run("layer.addData", &json!({"path": db.display().to_string(), "layer": "notes"})).is_err()); // Its layers are listed without adding them. let listed = s.run("info.fileLayers", &json!({"path": db.display().to_string()})).unwrap(); assert_eq!(listed, json!({"count": 2, "layers": ["lakes", "towns"]})); // The database commands take a DuckDB file too. let listed = s.run("info.databaseTables", &json!({"database": db.display().to_string()})).unwrap(); assert_eq!(listed, json!({"count": 2, "layers": ["lakes", "towns"]})); let r = s.run("layer.addDatabase", &json!({"database": db.display().to_string(), "layer": "lakes", "name": "L"})).unwrap(); assert_eq!(r["features"], 1); // A query over the database's tables. let r = s.run("layer.addSql", &json!({"sql": "SELECT * FROM towns", "database": db.display().to_string(), "name": "Q"})).unwrap(); assert_eq!(r["features"], 1); // Projected coordinates that don't say their system are pointed out; "crs" converts them. let projected = "SELECT ST_Point(500000, 4200000) AS geom"; s.run("layer.addSql", &json!({"sql": projected, "name": "Utm"})).unwrap(); assert!(s.notices.iter().any(|n| n.error && n.text.starts_with("Utm: the data doesn't say its coordinate system"))); let r = s.run("layer.addSql", &json!({"sql": projected, "name": "Utm2", "crs": "EPSG:32633"})).unwrap(); let e = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().extent(); assert!((e.min_x - 15.0).abs() < 0.01 && (e.min_y - 37.95).abs() < 0.05, "{e:?}"); let _ = std::fs::remove_dir_all(dir); } /// A source split by geometry type is added polygons first, so its points end on top and none hides /// under its own polygons, whether DuckDB or GDAL split it (#1488). Neither program is needed: the /// parts are files already written, as they would have converted them. #[cfg(not(target_arch = "wasm32"))] #[test] fn split_sources_keep_their_points_on_top() { use crate::cmd::layer::{add_converted, duckdb_parts, gdal_parts}; use rustgis_data::source::{Family, duckdb, gdal}; let dir = std::env::temp_dir().join(format!("rustgis-split-order-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let geometry = |f: Family| match f { Family::Points => r#"{"type": "Point", "coordinates": [10, 50]}"#, Family::Lines => r#"{"type": "LineString", "coordinates": [[10, 50], [11, 51]]}"#, Family::Polygons => r#"{"type": "Polygon", "coordinates": [[[10, 50], [11, 50], [11, 51], [10, 50]]]}"#, }; // A file per part, in the order both programs give them: points first (`Family::ALL`). let write = |stem: &str, f: Family| { let path = dir.join(format!("{stem}-{}.geojson", f.label())); let feature = format!(r#"{{"type": "Feature", "properties": {{}}, "geometry": {}}}"#, geometry(f)); std::fs::write(&path, format!(r#"{{"type": "FeatureCollection", "features": [{feature}]}}"#)).unwrap(); path }; let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); // A DuckDB query. let mut s = Session::new(Project::default()); let parts = Family::ALL.iter().map(|f| duckdb::Part { path: write("query", *f), family: *f, count: 1 }).collect(); let origin = |family| rustgis_map::source::DuckdbOrigin { sql: Some("SELECT 1".into()), database: None, table: None, crs: None, family }; let parts = duckdb_parts(&mut s, "Trip", duckdb::Output { parts, skipped: 0, other_family: 0 }, origin); add_converted(&mut s, &json!({}), "the query", "DuckDB query", parts).unwrap(); assert_eq!(names(&s), ["Trip (points)", "Trip (lines)", "Trip (polygons)"]); // A GDAL file with two mixed layers: each keeps its points on top, and the file's second layer // goes above its first, as any layer added after another does. let mut s = Session::new(Project::default()); let converted = ["a", "b"] .into_iter() .flat_map(|layer| Family::ALL.map(|f| (layer, f))) .map(|(layer, f)| gdal::Converted { path: write(layer, f), name: format!("{layer} ({})", f.label()), layer: layer.to_string(), family: Some(f), unprojected: false, }) .collect(); let original = dir.join("file.gpx").display().to_string(); add_converted(&mut s, &json!({}), &original, &original, gdal_parts(converted, &original, None)).unwrap(); assert_eq!(names(&s), ["b (points)", "b (lines)", "b (polygons)", "a (points)", "a (lines)", "a (polygons)"]); // A file the built-in readers split by type (a KML here, as GPX, OSM or DXF): the same order, // and the first layer added (the polygons, away from the rest) still frames an empty map on // its own extent, not on all the data's. let kml = r#" p10.5,50.5 l10,50 11,51 a12,52 13,52 13,53 12,52 "#; let path = dir.join("trip.kml"); std::fs::write(&path, kml).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(names(&s), ["trip points", "trip lines", "trip polygons"]); let c = s.map().center_lonlat(); assert!((c.x - 12.5).abs() < 0.1 && (c.y - 52.5).abs() < 0.2, "{c:?}"); // A KMZ's ground overlay goes under its layers, and the empty map is framed on the layers, not // on the picture. let mut red = tiny_skia::Pixmap::new(8, 8).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let doc = r#" Overfiles/red.png41401110 l30,30 31,31 "#; let kmz = rustgis_data::zip::write(&[("doc.kml".into(), doc.as_bytes().to_vec()), ("files/red.png".into(), red.encode_png().unwrap())]) .unwrap(); let path = dir.join("site.kmz"); std::fs::write(&path, kmz).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let all = names(&s); assert_eq!(all.last().map(String::as_str), Some("Over"), "{all:?}"); assert_eq!(all.len(), 2, "{all:?}"); let c = s.map().center_lonlat(); assert!((c.x - 30.5).abs() < 0.1 && (c.y - 30.5).abs() < 0.2, "{c:?}"); let _ = std::fs::remove_dir_all(dir); } /// PostgreSQL/PostGIS through DuckDB, when RUSTGIS_TEST_POSTGRES names a database with a /// password (`postgres:host=… dbname=… user=… password=…`) holding a PostGIS table. #[cfg(not(target_arch = "wasm32"))] #[test] fn postgres_databases() { let Ok(conn) = std::env::var("RUSTGIS_TEST_POSTGRES") else { return }; if !rustgis_data::source::duckdb::available() { return; } let mut s = Session::new(Project::default()); let listed = s.run("info.databaseTables", &json!({"database": conn})).unwrap(); let first = listed["layers"][0].as_str().unwrap().to_string(); let r = s.run("layer.addDatabase", &json!({"database": conn, "layer": first, "name": "P", "stream": true})).unwrap(); assert!(r["features"].as_u64().unwrap() > 0, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; let src = s.map().layer(id).unwrap().source.clone().unwrap(); // The project names the connection without its password, and converts the table again. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("postgres:") && !text.contains("password"), "{text}"); std::fs::remove_file(src.path.as_deref().unwrap()).unwrap(); // A project never connects to its server on its own (it would hand over the user's // credentials to whatever host the file names): the layer says to add the table again. let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("P").unwrap().source.as_ref().unwrap(); assert!(!again.is_open() && again.error.as_deref().is_some_and(|e| e.contains("add it again")), "{:?}", again.error); // A query over the database's tables. let r = s.run("layer.addSql", &json!({"sql": format!("SELECT * FROM {first} LIMIT 3"), "database": conn, "name": "Q"})).unwrap(); assert!(r["features"].as_u64().unwrap() <= 3, "{r}"); } #[test] fn pmtiles_archives_become_tile_layers() { let mut s = Session::new(Project::default()); // A file added as data is a tile layer of kind pmtiles (read when the map draws). let path = std::env::temp_dir().join("rustgis-test-archive.pmtiles"); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let t = &s.map().tiles[0]; assert_eq!(t.kind, rustgis_map::TileKind::PmTiles); assert_eq!(t.name, "rustgis-test-archive"); // A web address is guessed from its extension. s.run("map.addTiles", &json!({"url": "https://example.com/data/world.pmtiles"})).unwrap(); assert!(s.map().tiles.iter().any(|t| t.kind == rustgis_map::TileKind::PmTiles && t.url.starts_with("https://"))); // A relative path or another kind of file is refused. assert!(s.run("map.addTiles", &json!({"url": "world.pmtiles", "kind": "pmtiles"})).is_err()); assert!(s.run("map.addTiles", &json!({"url": "/tmp/x.txt", "kind": "pmtiles"})).is_err()); // A project keeps the layer. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(t.map().tiles.iter().filter(|x| x.kind == rustgis_map::TileKind::PmTiles).count(), 2); } /// Identify reports the features of a vector PMTiles archive under the point (topmost and /// visible modes), with their tile and source layers; hidden layers and the selectable mode don't. #[test] fn identify_reports_vector_tile_features() { use crate::render::rustgis_tiles::{mvt, pmtiles}; let park = mvt::Feature { id: Some(3), kind: mvt::GeomKind::Polygon, geometry: vec![vec![[1024.0, 1024.0], [3072.0, 1024.0], [3072.0, 3072.0], [1024.0, 3072.0], [1024.0, 1024.0]]], props: vec![("name".into(), mvt::Value::String("Central".into())), ("visitors".into(), mvt::Value::Number(1200.0))], }; let tile = mvt::encode(&mvt::Tile { layers: vec![mvt::Layer { name: "parks".into(), extent: 4096, features: vec![park] }] }); let new = pmtiles::NewArchive { tile_type: pmtiles::TileType::Mvt, gzipped_tiles: false, bounds: [-180.0, -85.0, 180.0, 85.0], metadata: json!({"vector_layers": [{"id": "parks"}]}), }; let dir = std::env::temp_dir().join(format!("rustgis-identify-tiles-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("parks.pmtiles"); std::fs::write(&path, pmtiles::write(vec![(0, 0, 0, tile)], &new).unwrap()).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); // Zoom 0, the park in the middle of the world. s.map_mut().camera.center = rustgis_geom::Coord::new(0.0, 0.0); s.map_mut().camera.res = 2.0 * 20_037_508.342_789_244 / 256.0; let identify = |s: &mut Session, p: Value| { // The archive and its tile arrive in the background. for _ in 0..500 { let r = s.run("identify", &p).unwrap(); if !r.is_null() { return r; } std::thread::sleep(std::time::Duration::from_millis(10)); } Value::Null }; let r = identify(&mut s, json!({"lon": 1.0, "lat": 1.0})); assert_eq!(r["tileLayer"], "parks", "{r}"); assert_eq!(r["sourceLayer"], "parks"); assert_eq!(r["layer"], "parks › parks"); assert_eq!(r["attributes"]["name"], "Central"); assert_eq!(r["attributes"]["visitors"], "1200"); assert!(r.get("layerId").is_none(), "{r}"); assert_eq!(r["results"].as_array().map(Vec::len), Some(1)); // Outside the park, in the selectable mode, or hidden: nothing. assert!(s.run("identify", &json!({"lon": 150.0, "lat": -70.0})).unwrap().is_null()); assert!(s.run("identify", &json!({"lon": 1.0, "lat": 1.0, "mode": "selectable"})).unwrap().is_null()); s.run("map.setTiles", &json!({"index": 0, "visible": false})).unwrap(); assert!(s.run("identify", &json!({"lon": 1.0, "lat": 1.0})).unwrap().is_null()); // A basemap from the catalog isn't identified, unless picked in Layers with Selected in Layers. s.run("map.setTiles", &json!({"index": 0, "visible": true})).unwrap(); (s.map_mut().tiles[0].id, s.map_mut().tiles[0].overlay) = ("parks-base".into(), false); assert!(s.run("identify", &json!({"lon": 1.0, "lat": 1.0})).unwrap().is_null()); let at = json!({"lon": 1.0, "lat": 1.0, "mode": "selectedInContents"}); assert!(s.run("identify", &at).unwrap().is_null(), "not picked"); s.run("map.selectTiles", &json!({"index": 0})).unwrap(); let r = s.run("identify", &at).unwrap(); assert_eq!((&r["tileLayer"], &r["attributes"]["name"]), (&json!("parks"), &json!("Central")), "{r}"); let _ = std::fs::remove_dir_all(dir); } /// A vector archive's source layers are listed by `map.tiles`; one switched off with /// `map.setTiles {hiddenLayers}` isn't identified, the setting undoes and is kept with the project, /// and a bad list is refused without changing anything. #[test] fn tile_source_layers_can_be_hidden() { use crate::render::rustgis_tiles::{mvt, pmtiles}; let feature = |kind, geometry: Vec>, name: &str| mvt::Feature { id: None, kind, geometry, props: vec![("name".into(), mvt::Value::String(name.into()))], }; let park = feature( mvt::GeomKind::Polygon, vec![vec![[1024.0, 1024.0], [3072.0, 1024.0], [3072.0, 3072.0], [1024.0, 3072.0], [1024.0, 1024.0]]], "Central", ); // Across the park, through the middle of the world. let road = feature(mvt::GeomKind::LineString, vec![vec![[0.0, 2048.0], [4096.0, 2048.0]]], "Equator Road"); let layer = |name: &str, f| mvt::Layer { name: name.into(), extent: 4096, features: vec![f] }; let tile = mvt::encode(&mvt::Tile { layers: vec![layer("parks", park), layer("roads", road)] }); let new = pmtiles::NewArchive { tile_type: pmtiles::TileType::Mvt, gzipped_tiles: false, bounds: [-180.0, -85.0, 180.0, 85.0], // Names no tile layer keeps (empty, too long) and repeats don't use up the 256 listed, nor // push `roads` out of the archive's look: it is listed, drawn, identified and hidden. metadata: json!({"vector_layers": std::iter::repeat_n(json!({"id": "parks"}), 300) .chain((0..300).map(|i| json!({"id": format!("{}{i}", "x".repeat(300))}))) .chain([json!({"id": ""}), json!({"id": "roads"})]) .collect::>()}), }; let dir = std::env::temp_dir().join(format!("rustgis-tile-source-layers-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("city.pmtiles"); std::fs::write(&path, pmtiles::write(vec![(0, 0, 0, tile)], &new).unwrap()).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); s.map_mut().camera.center = rustgis_geom::Coord::new(0.0, 0.0); s.map_mut().camera.res = 2.0 * 20_037_508.342_789_244 / 256.0; // The archive's header arrives in the background. let mut listed = Value::Null; for _ in 0..500 { listed = s.run("map.tiles", &json!({})).unwrap()[0].clone(); if listed.get("sourceLayers").is_some() { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } assert_eq!(listed["sourceLayers"], json!(["parks", "roads"]), "{listed}"); assert!(listed.get("hiddenLayers").is_none()); let found = |s: &mut Session| -> Vec { // The tile arrives in the background too. for _ in 0..500 { let r = s.run("identify", &json!({"lon": 1.0, "lat": 0.0, "mode": "visible"})).unwrap(); if !r.is_null() { let mut names: Vec = r["results"].as_array().unwrap().iter().map(|x| x["sourceLayer"].as_str().unwrap_or_default().to_string()).collect(); names.sort(); return names; } std::thread::sleep(std::time::Duration::from_millis(10)); } Vec::new() }; assert_eq!(found(&mut s), ["parks", "roads"]); s.run("map.setTiles", &json!({"index": 0, "hiddenLayers": ["parks"]})).unwrap(); assert_eq!(s.map().tiles[0].hidden_layers, ["parks"]); assert_eq!(s.run("map.tiles", &json!({})).unwrap()[0]["hiddenLayers"], json!(["parks"])); assert_eq!(found(&mut s), ["roads"]); // Undone, the park is back; redone, it's hidden again. s.run("edit.undo", &json!({})).unwrap(); assert!(s.map().tiles[0].hidden_layers.is_empty()); assert_eq!(found(&mut s), ["parks", "roads"]); s.run("edit.redo", &json!({})).unwrap(); assert_eq!(found(&mut s), ["roads"]); // Kept with the project. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains(r#""hiddenLayers":["parks"]"#), "{text}"); let mut reopened = Session::new(Project::default()); reopened.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(reopened.map().tiles[0].hidden_layers, ["parks"]); // A damaged entry in a project is dropped, not fatal. let damaged = text.replace(r#""hiddenLayers":["parks"]"#, r#""hiddenLayers":[3,"parks",""]"#); let mut reopened = Session::new(Project::default()); reopened.run("project.openText", &json!({"text": damaged})).unwrap(); assert_eq!(reopened.map().tiles[0].hidden_layers, ["parks"]); // Repeats don't use up the cap: each name counts once. let repeated = format!(r#""hiddenLayers":[{}"roads"]"#, r#""parks","#.repeat(300)); let mut reopened = Session::new(Project::default()); reopened.run("project.openText", &json!({"text": text.replace(r#""hiddenLayers":["parks"]"#, &repeated)})).unwrap(); assert_eq!(reopened.map().tiles[0].hidden_layers, ["parks", "roads"]); // Bad lists, and a raster layer's, are refused and change nothing. let too_many: Vec = (0..=rustgis_map::tiles::MAX_SOURCE_LAYERS).map(|i| format!("l{i}")).collect(); for bad in [json!("parks"), json!([1]), json!({"parks": true}), json!(too_many)] { assert!(s.run("map.setTiles", &json!({"index": 0, "hiddenLayers": bad, "opacity": 0.5})).is_err(), "{bad}"); } assert_eq!((s.map().tiles[0].hidden_layers.clone(), s.map().tiles[0].opacity), (vec!["parks".to_string()], 1.0)); s.run("map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "name": "Imagery"})).unwrap(); assert!(s.run("map.setTiles", &json!({"name": "Imagery", "hiddenLayers": ["parks"]})).is_err()); // Each name once; null shows them all again. s.run("map.setTiles", &json!({"name": "city", "hiddenLayers": ["roads", "parks", "roads"]})).unwrap(); let city = s.map().tiles.iter().position(|t| t.name == "city").unwrap(); assert_eq!(s.map().tiles[city].hidden_layers, ["parks", "roads"]); s.run("map.setTiles", &json!({"name": "city", "hiddenLayers": null})).unwrap(); assert!(s.map().tiles[city].hidden_layers.is_empty()); let _ = std::fs::remove_dir_all(dir); } /// A tile-less PMTiles archive (an empty root directory) whose header gives these bounds /// (west, south, east, north). fn empty_pmtiles(bounds: [f64; 4]) -> Vec { let mut h = b"PMTiles".to_vec(); h.push(3); // Root directory (one byte: no entries), no metadata, leaves or tiles. for v in [127u64, 1, 128, 0, 128, 0, 128, 0, 0, 0, 0] { h.extend_from_slice(&v.to_le_bytes()); } h.extend_from_slice(&[1, 1, 1, 1, 0, 6]); // clustered, no compression, vector tiles, zooms 0–6 for v in bounds { h.extend_from_slice(&((v * 1e7) as i32).to_le_bytes()); } h.extend_from_slice(&[0u8; 9]); h.push(0); h } /// An archive added frames the map on its bounds once its header is read, unless it covers the /// whole world; Zoom To Tile Layer goes there again, and a layer that can't say is refused. #[test] fn pmtiles_archives_frame_the_map_on_their_bounds() { let dir = std::env::temp_dir().join(format!("rustgis-pmtiles-frame-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let iceland = dir.join("iceland.pmtiles"); std::fs::write(&iceland, empty_pmtiles([-24.5, 63.3, -13.5, 66.6])).unwrap(); let world = dir.join("world.pmtiles"); std::fs::write(&world, empty_pmtiles([-180.0, -85.0, 180.0, 85.0])).unwrap(); let center = |s: &Session| rustgis_geom::merc_to_lonlat(s.map().camera.center); let wait = |s: &mut Session| { let start = std::time::Instant::now(); while s.style_view.is_some() && start.elapsed() < std::time::Duration::from_secs(20) { crate::cmd::tiles::follow_style_view(s, web_time::Duration::from_secs(5)); } }; let mut s = Session::new(Project::default()); // A file's header is read as it is added: the next command sees the map moved. s.run("layer.addData", &json!({"path": iceland.display().to_string()})).unwrap(); assert!(s.style_view.is_none()); let c = center(&s); assert!((c.x + 19.0).abs() < 0.5 && (c.y - 65.0).abs() < 1.5, "{c:?}"); // A world archive leaves the map where it is. s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = s.map().camera.center; s.run("map.addTiles", &json!({"url": world.display().to_string()})).unwrap(); wait(&mut s); assert_eq!(s.map().camera.center, before); // Zoom To Tile Layer goes back to the first; a layer with no bounds is refused. let r = s.run("map.zoomToTiles", &json!({"name": "iceland"})).unwrap(); assert_eq!(r["bounds"][0].as_f64().map(|w| (w + 24.5).abs() < 1e-6), Some(true), "{r}"); let c = center(&s); assert!((c.x + 19.0).abs() < 0.5, "{c:?}"); // Search finds archives too and uses the header, even without service metadata bounds. s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let r = s.run("map.search", &json!({"query": "iceland", "go": true})).unwrap(); assert_eq!(r["hits"][0]["kind"], "tile"); let c = center(&s); assert!((c.x + 19.0).abs() < 0.5 && (c.y - 65.0).abs() < 1.5, "{c:?}"); s.run("map.addTiles", &json!({"url": "https://tile.example.com/{z}/{x}/{y}.png", "name": "xyz"})).unwrap(); assert!(s.run("map.zoomToTiles", &json!({"name": "xyz"})).is_err()); assert!(s.run("map.zoomToTiles", &json!({})).is_err()); let _ = std::fs::remove_dir_all(dir); } #[test] fn explicit_tile_bounds_are_rejected_without_replacing_the_layer() { let mut s = Session::new(Project::default()); let url = "https://tiles.example.com/{z}/{x}/{y}.png"; s.run("map.addTiles", &json!({"url": url, "name": "Coverage", "bounds": [5, 45, 15, 55]})).unwrap(); let before = serde_json::to_value(&s.project).unwrap(); for bounds in [ json!([20, 0, 10, 10]), json!([0, 20, 10, 10]), json!([10, 0, 10, 10]), json!([0, 10, 10, 10]), json!([-181, 0, 10, 10]), json!([0, 0, 181, 10]), json!([0, -91, 10, 10]), json!([0, 0, 10, 91]), json!([0, 0, 1e300, 10]), json!([0, 0, 10]), json!([0, "south", 10, 10]), json!("not bounds"), ] { let r = s.run("map.addTiles", &json!({"url": url, "name": "Coverage", "bounds": bounds, "replace": true})); assert!(r.is_err(), "{bounds}: {r:?}"); assert_eq!(serde_json::to_value(&s.project).unwrap(), before, "{bounds}"); } let r = s.run("map.zoomToTiles", &json!({"name": "Coverage"})).unwrap(); assert_eq!(r["bounds"], json!([5.0, 45.0, 15.0, 55.0])); } /// Bad optional coverage must not prevent loading the tiles or the rest of the project. #[test] fn malformed_saved_tile_bounds_do_not_prevent_opening_the_project() { let mut s = world(); let url = "https://tiles.example.com/{z}/{x}/{y}.png"; s.run("map.addTiles", &json!({"url": url, "name": "Coverage", "bounds": [5, 45, 15, 55], "opacity": 0.5})).unwrap(); s.run("map.addTiles", &json!({"url": "https://other.example.com/{z}/{x}/{y}.png", "name": "Valid", "bounds": [1, 2, 3, 4]})).unwrap(); let mut project = serde_json::to_value(&s.project).unwrap(); let coverage = project["maps"][0]["tiles"].as_array().unwrap().iter().position(|t| t["name"] == "Coverage").unwrap(); for bounds in [ json!([0, 0, 10]), json!([0, 0, 10, 10, 20]), json!([0, "south", 10, 10]), json!([0, {"nested": [1, 2]}, 10, 10]), json!([[0], 0, 10, 10]), json!([]), json!({"nested": [1, 2]}), json!("x"), json!(true), json!(42), Value::Null, json!([20, 0, 10, 10]), ] { project["maps"][0]["tiles"][coverage]["bounds"] = bounds.clone(); let loaded = Project::from_json(&project.to_string()).unwrap_or_else(|e| panic!("{bounds}: {e}")); let tile = loaded.map().tiles.iter().find(|t| t.name == "Coverage").unwrap(); assert_eq!(tile.bounds, None, "{bounds}"); assert_eq!((tile.url.as_str(), tile.opacity, tile.visible), (url, 0.5, true), "{bounds}"); assert_eq!(loaded.map().tiles.iter().find(|t| t.name == "Valid").unwrap().bounds, Some([1.0, 2.0, 3.0, 4.0])); assert_eq!( loaded.map().layer_by_name("Countries").unwrap().dataset.features.len(), s.map().layer_by_name("Countries").unwrap().dataset.features.len() ); } project["maps"][0]["tiles"][coverage].as_object_mut().unwrap().remove("bounds"); let loaded = Project::from_json(&project.to_string()).unwrap(); assert_eq!(loaded.map().tiles.iter().find(|t| t.name == "Coverage").unwrap().bounds, None); } /// An MBTiles file of one red picture tile: added by path or as an upload, it is a tile layer that /// draws, survives a project round trip (by path), and a file that isn't an archive is refused. #[test] fn mbtiles_archives_become_tile_layers() { use crate::render::View; use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-mbtiles-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("red.mbtiles"); let _ = std::fs::remove_file(&path); let mut red = tiny_skia::Pixmap::new(256, 256).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let c = rusqlite::Connection::open(&path).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Red'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '0'), ('attribution', '© Red');", ) .unwrap(); c.execute("INSERT INTO tiles VALUES (0, 0, 0, ?1)", [red.encode_png().unwrap()]).unwrap(); drop(c); let p = path.display().to_string(); let draws_red = |s: &mut Session| { s.run("map.goToXY", &json!({"lon": 10.0, "lat": 20.0, "scale": 50_000_000})).unwrap(); let v = View { width: 300.0, height: 200.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; let mut px = None; for _ in 0..50 { let r = crate::render::render(s.map_mut(), &v).unwrap(); px = r.pixmap.pixel(150, 100); if px.is_some_and(|p| p.red() > 200 && p.green() < 60) { return; } crate::render::rustgis_tiles::cache::wait(std::time::Duration::from_millis(100)); } panic!("the tile layer didn't draw: {px:?}"); }; let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": p})).unwrap(); let t = &s.map().tiles[0]; assert_eq!((t.kind, t.name.as_str()), (rustgis_map::TileKind::MbTiles, "red")); draws_red(&mut s); // A project keeps the layer by its path. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!(t.map().tiles.first().map(|x| x.kind), Some(rustgis_map::TileKind::MbTiles)); draws_red(&mut t); // An upload is held in memory. let b64 = base64::engine::general_purpose::STANDARD.encode(std::fs::read(&path).unwrap()); let mut u = Session::new(Project::default()); u.run("layer.addDataBytes", &json!({"fileName": "uploaded.mbtiles", "data": b64})).unwrap(); assert_eq!(u.map().tiles[0].url, "upload:uploaded.mbtiles"); draws_red(&mut u); // Added as data to a map with layers, it goes on top of them (#808), and draws there. let mut w = world(); w.run("layer.addData", &json!({"path": p})).unwrap(); assert_eq!(stack_names(&w)[0], "~red"); draws_red(&mut w); // Not an archive, a relative path, a network share, an upload never made. let junk = dir.join("junk.mbtiles"); std::fs::write(&junk, b"not sqlite").unwrap(); assert!(s.run("layer.addData", &json!({"path": junk.display().to_string()})).is_err()); let junk64 = base64::engine::general_purpose::STANDARD.encode(b"not sqlite"); assert!(s.run("layer.addDataBytes", &json!({"fileName": "junk.mbtiles", "data": junk64})).is_err()); for bad in ["red.mbtiles", r"\\attacker\share\x.mbtiles", "/tmp/x.pmtiles", "upload:"] { assert!(s.run("map.addTiles", &json!({"url": bad, "kind": "mbtiles"})).is_err(), "{bad}"); } let _ = std::fs::remove_dir_all(dir); } /// An MBTiles archive added frames the map on its metadata bounds, as a PMTiles archive does, over /// a map that already holds content too, unless it covers the whole world; Zoom To Tile Layer goes /// there again and `"zoom"` is ignored, as it is for PMTiles. #[test] fn mbtiles_archives_frame_the_map_on_their_bounds() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-mbtiles-frame-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut red = tiny_skia::Pixmap::new(256, 256).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let png = red.encode_png().unwrap(); let write = |name: &str, bounds: Option<&str>| { let path = dir.join(name); let _ = std::fs::remove_file(&path); let c = rusqlite::Connection::open(&path).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob);", ) .unwrap(); c.execute("INSERT INTO metadata VALUES ('format', 'png'), ('minzoom', '0'), ('maxzoom', '0')", []).unwrap(); if let Some(b) = bounds { c.execute("INSERT INTO metadata VALUES ('bounds', ?1)", [b]).unwrap(); } c.execute("INSERT INTO tiles VALUES (0, 0, 0, ?1)", [png.as_slice()]).unwrap(); drop(c); path.display().to_string() }; let center = |s: &Session| rustgis_geom::merc_to_lonlat(s.map().camera.center); let near_iceland = |s: &Session| { let c = center(s); assert!((c.x + 19.0).abs() < 0.5 && (c.y - 65.0).abs() < 1.5, "{c:?}"); }; let iceland = write("iceland.mbtiles", Some("-24.5,63.3,-13.5,66.6")); // Added as data on an empty map: framed on its bounds straight away (read natively). let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": iceland})).unwrap(); assert!(s.style_view.is_none()); near_iceland(&s); // A locked archive must not hold the UI thread: frame it after its metadata becomes ready. let locked = write("locked.mbtiles", Some("-24.5,63.3,-13.5,66.6")); let connection = rusqlite::Connection::open(&locked).unwrap(); connection.execute_batch("BEGIN EXCLUSIVE").unwrap(); let mut delayed = Session::new(Project::default()); let start = std::time::Instant::now(); let result = delayed.run("map.addTiles", &json!({"url":locked,"kind":"mbtiles"})).unwrap(); assert!(start.elapsed() < std::time::Duration::from_secs(2), "metadata wait exceeded its one-second bound"); assert_eq!(result["zoomed"], false); assert!(delayed.style_view.is_some()); delayed.run("map.setTiles", &json!({"index":0,"opacity":0.5,"visible":false,"rename":"Renamed archive","move":"bottom"})).unwrap(); connection.execute_batch("ROLLBACK").unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); while delayed.style_view.is_some() { assert!(std::time::Instant::now() < deadline, "metadata never became ready"); delayed.follow_style_view(); std::thread::sleep(std::time::Duration::from_millis(5)); } near_iceland(&delayed); // Switching to a duplicate with equal tiles and camera cannot consume the original's request. let copied = write("copied.mbtiles", Some("-24.5,63.3,-13.5,66.6")); let connection = rusqlite::Connection::open(&copied).unwrap(); connection.execute_batch("BEGIN EXCLUSIVE").unwrap(); let mut switched = Session::new(Project::default()); switched.run("map.addTiles", &json!({"url":copied,"kind":"mbtiles"})).unwrap(); assert!(switched.style_view.is_some()); switched.run("map.duplicate", &json!({"name":"Copy"})).unwrap(); let camera = switched.map().camera; // A catalog replacement of the same archive must not inherit an ID-less framing request. let mut catalog = Session::new(Project::default()); catalog.run("map.addTiles", &json!({"url":copied,"kind":"mbtiles"})).unwrap(); assert!(catalog.style_view.is_some()); catalog.run("map.addTiles", &json!({"url":copied,"kind":"mbtiles","id":"catalog-archive"})).unwrap(); let catalog_camera = catalog.map().camera; connection.execute_batch("ROLLBACK").unwrap(); assert!(!catalog.follow_style_view()); assert!(catalog.style_view.is_none()); assert_eq!(catalog.map().camera, catalog_camera); assert!(!switched.follow_style_view()); assert!(switched.style_view.is_none()); assert_eq!(switched.map().camera, camera); // A full metadata gate defers the ninth archive rather than losing its framing request. let mut locks = Vec::new(); let mut saturated = String::new(); for i in 0..9 { let path = write(&format!("busy-{i}.mbtiles"), Some("-24.5,63.3,-13.5,66.6")); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("BEGIN EXCLUSIVE").unwrap(); assert!(rustgis_render::rustgis_tiles::mbtiles::info_ready(&path, web_time::Duration::ZERO).unwrap().is_none()); locks.push(conn); saturated = path; } let mut queued = Session::new(Project::default()); let result = queued.run("map.addTiles", &json!({"url": saturated, "kind":"mbtiles"})).unwrap(); assert_eq!(result["zoomed"], false); assert!(queued.style_view.is_some()); for conn in locks { conn.execute_batch("ROLLBACK").unwrap(); } let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); while queued.style_view.is_some() { assert!(std::time::Instant::now() < deadline, "queued archive never framed after capacity became available"); queued.follow_style_view(); std::thread::sleep(std::time::Duration::from_millis(5)); } near_iceland(&queued); // A newer requested view wins; the earlier slow archive must not move the camera later. let older = write("older.mbtiles", Some("-24.5,63.3,-13.5,66.6")); let conn = rusqlite::Connection::open(&older).unwrap(); conn.execute_batch("BEGIN EXCLUSIVE").unwrap(); let mut latest = Session::new(Project::default()); let start = std::time::Instant::now(); latest.run("layer.addData", &json!({"path":older})).unwrap(); assert!(start.elapsed() < std::time::Duration::from_secs(2), "local Add Data shares the one-second header budget"); assert!(latest.style_view.is_some()); let newer = write("newer.mbtiles", Some("130,30,145,45")); latest.run("map.addTiles", &json!({"url":newer,"kind":"mbtiles"})).unwrap(); let chosen = latest.map().camera; assert!(center(&latest).x > 130.0); conn.execute_batch("ROLLBACK").unwrap(); latest.follow_style_view(); assert!(latest.style_view.is_none()); assert_eq!(latest.map().camera, chosen); // Over a map that holds content, the archive is still framed (see `follow_style_view`). let mut w = world(); w.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); w.run("layer.addData", &json!({"path": iceland})).unwrap(); near_iceland(&w); // The added base archive owns its framing, not an earlier same-URL overlay's bounds. w.run("map.addTiles", &json!({"url": iceland, "kind":"mbtiles", "overlay":true, "name":"Other look", "bounds":[130,30,145,45]})) .unwrap(); w.run("layer.addData", &json!({"path":iceland})).unwrap(); near_iceland(&w); // An archive decides for itself: `"zoom": false` doesn't stop it (documented in layer.addUrl). s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); s.run("layer.addData", &json!({"path": iceland, "zoom": false})).unwrap(); near_iceland(&s); // Zoom To Tile Layer reads the same metadata. s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let r = s.run("map.zoomToTiles", &json!({"name": "iceland"})).unwrap(); assert_eq!(r["bounds"][0].as_f64().map(|w| (w + 24.5).abs() < 1e-6), Some(true), "{r}"); near_iceland(&s); // An upload's bytes are framed too. let b64 = base64::engine::general_purpose::STANDARD.encode(std::fs::read(dir.join("iceland.mbtiles")).unwrap()); let mut u = Session::new(Project::default()); u.run("layer.addDataBytes", &json!({"fileName": "uploaded.mbtiles", "data": b64})).unwrap(); near_iceland(&u); // The download result reports the archive's actual framing instead of looking only for feature layers. let mut downloaded = Session::new(Project::default()); let id = downloaded.download_started(); assert!(downloaded.download_landed(id, std::fs::read(&iceland).unwrap())); let result = downloaded.run("layer.addDownload", &json!({"id": id, "url": "https://example.org/iceland.mbtiles", "zoom": true})).unwrap(); near_iceland(&downloaded); assert_eq!(result["zoomed"], true); // A world archive, or one with no bounds, leaves the map where it is. for (name, bounds) in [("world.mbtiles", Some("-180,-85,180,85")), ("bare.mbtiles", None)] { let p = write(name, bounds); let mut w = Session::new(Project::default()); w.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = w.map().camera; let result = w.run("layer.addData", &json!({"path": p})).unwrap(); assert_eq!(result["zoomed"], false, "{name}"); assert_eq!(w.map().camera, before, "{name}"); } let _ = std::fs::remove_dir_all(dir); } /// Delimited text in a projected system, by path, text and upload; WKT columns; bad options. #[test] fn delimited_text_with_a_coordinate_system() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-csv-crs-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("parks.tsv"); std::fs::write(&path, "name\tX\tY\nCentral Park\t992000\t220000\n").unwrap(); let p = path.display().to_string(); let near_nyc = |s: &Session, id: &Value| { let e = s.map().layer(id.as_u64().unwrap() as u32).unwrap().extent(); assert!((e.min_x + 73.96).abs() < 0.05 && (e.min_y - 40.78).abs() < 0.05, "{e:?}"); }; let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": p, "crs": "EPSG:2263"})).unwrap(); near_nyc(&s, &r["layer"]); let text = "id;east;north\n1;992000;220000\n"; let r = s.run("layer.addDataText", &json!({"fileName": "a.csv", "text": text, "x": "east", "y": "north", "crs": "2263"})).unwrap(); near_nyc(&s, &r["layer"]); let b64 = base64::engine::general_purpose::STANDARD.encode(text); let r = s .run("layer.addDataBytes", &json!({"fileName": "b.csv", "data": b64, "x": "east", "y": "north", "crs": "EPSG:2263", "name": "Up"})) .unwrap(); near_nyc(&s, &r["layer"]); assert!(s.map().layer_by_name("Up").is_some()); // Without options a WKT column is found; with a SRID it is placed. let r = s.run("layer.addDataText", &json!({"fileName": "w.csv", "text": "wkt,n\n\"SRID=2263;POINT (992000 220000)\",1\n"})).unwrap(); near_nyc(&s, &r["layer"]); for (bad, why) in [ (json!({"crs": "nowhere"}), "unknown system"), (json!({"x": "missing", "y": "north"}), "missing column"), (json!({"x": "east"}), "x without y"), (json!({"delimiter": ";;"}), "two-character delimiter"), ] { let mut q = json!({"fileName": "a.csv", "text": text}); for (k, v) in bad.as_object().unwrap() { q[k] = v.clone(); } assert!(s.run("layer.addDataText", &q).is_err(), "{why}"); } let _ = std::fs::remove_dir_all(dir); } /// Serves `body` to each of `n` requests on a local port; returns the base address. pub(crate) fn serve(body: &'static [u8], n: usize) -> String { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); std::thread::spawn(move || { for stream in listener.incoming().take(n) { let Ok(mut s) = stream else { continue }; let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]); let status = if req.contains("/missing") { "404 Not Found" } else { "200 OK" }; let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = s.write_all(body); } }); format!("http://{addr}") } /// Serves each file by its exact path and query (others are 404) on a local port, keeping the /// paths asked for; returns the base address and them. fn serve_files(files: Vec<(String, Vec)>) -> (String, std::sync::Arc>>) { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let asked = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let log = asked.clone(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]); let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); log.lock().unwrap().push(path.clone()); match files.iter().find(|(p, _)| *p == path) { Some((_, body)) => { let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = s.write_all(body); } // A missing file under `/signed/` or `/s3/` is forbidden, as S3 answers a // signature that doesn't cover it, or a missing object to those who can't list. None if path.starts_with("/signed/") || path.starts_with("/s3/") => { let _ = write!(s, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); } None => { let _ = write!(s, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); } } } }); (base, asked) } /// A registry whose blocklist can't be read warns that the last blocklist read stays in force; /// one with a readable blocklist says nothing, apart from naming the plugins it turned off. #[test] fn unreadable_plugin_blocklist_warns() { let warned = |blocklist: &[u8]| { let (base, _) = serve_files(vec![("/registry.json".into(), br#"{"plugins": []}"#.to_vec()), ("/blocklist.json".into(), blocklist.to_vec())]); let mut s = Session::new(Project::default()); let mark = s.notices_given(); s.run("plugin.registry", &json!({"url": format!("{base}/registry.json")})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().filter(|n| n.text.contains("stays in force")).collect(); assert!(said.iter().all(|n| n.severity == rustgis_data::diagnostics::Severity::Warning), "{said:?}"); // It names the blocklist and why it was refused. assert!( said.iter().all(|n| n.text.contains(&format!("{base}/blocklist.json")) && n.text.contains("no 'blocked' list")), "{said:?}" ); said.len() }; assert_eq!(warned(br#"{"blocked": {}}"#), 1); assert_eq!(warned(br#"{"blocked": []}"#), 0); // In the same test, as both apply blocklists, which unload plugins process-wide: one that // isn't text is refused with a warning, and one that withdraws a loaded plugin says so. let run = |blocklist: &[u8]| { let (base, _) = serve_files(vec![("/registry.json".into(), br#"{"plugins": []}"#.to_vec()), ("/blocklist.json".into(), blocklist.to_vec())]); let mut s = Session::new(Project::default()); let mark = s.notices_given(); s.run("plugin.registry", &json!({"url": format!("{base}/registry.json")})).unwrap(); s.notices_since(mark).iter().map(|n| n.text.clone()).collect::>() }; let said = run(b"[{\"id\": \"eng-\xff\"}]"); assert!(said.iter().any(|t| t.contains("isn't text") && t.contains("stays in force")), "{said:?}"); plugin("eng-withdrawn", &[]); let said = run(br#"[{"id": "eng-withdrawn"}]"#); assert!(said.iter().any(|t| t.contains("1 plugin(s) were withdrawn") && t.contains("Test eng-withdrawn")), "{said:?}"); assert!(rustgis_plugins::get("eng-withdrawn").is_none()); } /// A Shapefile published as loose files is added by its `.shp` address (#1238): its `.dbf`, /// `.shx`, `.prj` and `.cpg` are fetched beside it with the address's query, and its `.prj` places /// it; the parts that aren't there are done without, said, but not its `.dbf`, nor a part a /// signed address is refused. An address answering with a zip is read as one. Left to the app (a /// plugin's command), the address waits for the app, which fetches the parts the same way. #[test] fn shapefile_by_its_shp_address() { use rustgis_data::raster::Crs; // A point in UTM 23S (Teresina), written as a Shapefile's parts. let (utm, _) = rustgis_data::io::read_geojson( r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"nome":"rio"},"geometry":{"type":"Point","coordinates":[742000,9390000]}}]}"#, "rios", ) .unwrap(); let (parts, _) = rustgis_data::io::shapefile_parts_in(&utm, Crs::from_epsg(31983).unwrap()).unwrap(); let mut files: Vec<(String, Vec)> = parts.iter().map(|(e, b)| (format!("/PI/TERESINA/HIDROGRAFIA/PI_2211001_RIOS.{e}?token=1"), b.clone())).collect(); // The same without its .prj and .cpg, and one without its .dbf. files.extend(parts.iter().filter(|(e, _)| !matches!(*e, "prj" | "cpg")).map(|(e, b)| (format!("/plain/rios.{e}"), b.clone()))); files.extend(parts.iter().filter(|(e, _)| *e != "dbf").map(|(e, b)| (format!("/nodbf/rios.{e}"), b.clone()))); files.extend(parts.iter().filter(|(e, _)| *e != "shx").map(|(e, b)| (format!("/noshx/rios.{e}"), b.clone()))); // One whose signature doesn't cover its .prj, and one whose address answers with its parts zipped. files.extend(parts.iter().filter(|(e, _)| *e != "prj").map(|(e, b)| (format!("/signed/rios.{e}?sig=1"), b.clone()))); files.extend(parts.iter().filter(|(e, _)| *e != "prj").map(|(e, b)| (format!("/s3/rios.{e}"), b.clone()))); files.extend(parts.iter().filter(|(e, _)| *e != "prj").map(|(e, b)| (format!("/s3/v.{e}?v=2"), b.clone()))); // A site that answers any address with its page: the .prj and .cpg come back as HTML. let page = b"\napp".to_vec(); files.extend(parts.iter().filter(|(e, _)| !matches!(*e, "prj" | "cpg")).map(|(e, b)| (format!("/spa/rios.{e}"), b.clone()))); files.extend(["prj", "cpg"].map(|e| (format!("/spa/rios.{e}"), page.clone()))); files.extend(parts.iter().filter(|(e, _)| *e == "shp").map(|(e, b)| (format!("/spa/sem_dbf.{e}"), b.clone()))); files.push(("/spa/sem_dbf.dbf".into(), page.clone())); files.extend(parts.iter().filter(|(e, _)| *e == "shp").map(|(e, b)| (format!("/spa/tk.{e}?token=1"), b.clone()))); files.push(("/spa/tk.dbf?token=1".into(), page.clone())); files.extend(parts.iter().filter(|(e, _)| *e == "shp").map(|(e, b)| (format!("/bad/tk.{e}?token=1"), b.clone()))); files.push(("/bad/tk.dbf?token=1".into(), b"not a dbf".to_vec())); files.push(("/spa/pagina.shp".into(), page.clone())); files.extend(parts.iter().filter(|(e, _)| *e == "shp").map(|(e, b)| (format!("/s3/sem_dbf.{e}"), b.clone()))); let shp = parts.iter().find(|(e, _)| *e == "shp").unwrap().1.clone(); let others = parts.iter().filter(|(e, _)| *e != "shp").map(|(e, b)| (*e, b.clone())).collect(); files.push(("/zipped/rios.shp".into(), rustgis_data::io::zip_shapefile_parts("rios.shp", shp.clone(), others).unwrap())); // A zip of two Shapefiles: a complete, b without its .prj. let two: Vec<(String, Vec)> = parts .iter() .map(|(e, b)| (format!("a.{e}"), b.clone())) .chain(parts.iter().filter(|(e, _)| *e != "prj").map(|(e, b)| (format!("b.{e}"), b.clone()))) .collect(); files.push(("/zipped/dois.shp".into(), rustgis_data::zip::write(&two).unwrap())); let (base, asked) = serve_files(files); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/PI/TERESINA/HIDROGRAFIA/PI_2211001_RIOS.shp?token=1")})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(!s.notices.iter().any(|n| n.text.contains("optional files")), "{:?}", s.notices); let e = s.map().layer_by_name("PI_2211001_RIOS").unwrap().extent(); assert!((-43.0..-42.6).contains(&e.min_x) && (-5.7..-5.3).contains(&e.min_y), "placed by its .prj: {e:?}"); let asked_for = asked.lock().unwrap().clone(); for ext in ["shp", "dbf", "shx", "prj", "cpg"] { assert!(asked_for.contains(&format!("/PI/TERESINA/HIDROGRAFIA/PI_2211001_RIOS.{ext}?token=1")), "{ext}: {asked_for:?}"); } // Without a .prj or .cpg (404), it is read as a local Shapefile without them. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/plain/rios.shp"), "name": "Sem prj"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); let said = &s.notices.last().unwrap().text; assert!(said.contains("no .prj, .cpg found or readable beside it (2 of 3") && said.contains("longitude/latitude"), "{said}"); // Their 404s are expected: noted once for the server, not recorded as failed requests. let records = s.diagnostics.records(); let failed = |r: &&rustgis_data::diagnostics::Record| { r.severity == rustgis_data::diagnostics::Severity::Error && r.network.as_ref().is_some_and(|n| n.url.contains("/plain/rios.")) }; assert!(!records.iter().any(|r| failed(&r)), "{records:?}"); assert!(records.iter().any(|r| r.message.starts_with("expected absences are omitted")), "{records:?}"); // With its coordinate system given, nothing is assumed of the missing .prj. s.run("layer.addUrl", &json!({"url": format!("{base}/plain/rios.shp"), "name": "Com crs", "crs": "EPSG:31983"})).unwrap(); let said = &s.notices.last().unwrap().text; assert!(said.contains("no .prj, .cpg found or readable beside it") && !said.contains("longitude/latitude"), "{said}"); // A long name keeps its extension (and is still a .shp's zip). let long = crate::cmd::layer::url_file_name(&format!("https://x.org/{}.shp", "a".repeat(200)), b"PK\x03\x04").unwrap(); assert!(long.ends_with(".zip") && long.chars().count() <= 120, "{long}"); let long_json = crate::cmd::layer::url_file_name(&format!("https://x.org/{}.geojson", "a".repeat(200)), b"{}").unwrap(); assert!(long_json.ends_with(".geojson") && long_json.chars().count() <= 120, "{long_json}"); // A part a signed address is refused (403) fails it, named: the signature may not cover it. // It names the part without the signature. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/signed/rios.shp?sig=1")})).unwrap_err(); assert!(e.contains("/signed/rios.prj") && e.contains("403") && !e.contains("sig=1"), "{e}"); assert!( e.contains("may also mean the file isn't there") && e.contains( "keys or signature don't cover it: a presigned address signs one file, so a Shapefile's parts need addresses of their own" ), "{e}" ); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/signed/gone.shp?sig=1")})).unwrap_err(); assert!(e.contains("/signed/gone.shp") && e.contains("403") && !e.contains("sig=1"), "{e}"); // An unsigned address answered 403 for a part (S3, for a missing object) is done without. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/s3/rios.shp"), "name": "S3"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.notices.last().unwrap().text.contains("no .prj found or readable beside it (1 of 3"), "{:?}", s.notices.last()); // So with a query that carries no key (a version, a download flag). let r = s.run("layer.addUrl", &json!({"url": format!("{base}/s3/v.shp?v=2"), "name": "S3 v2"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); // And with a fragment, which carries no key either. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/s3/rios.shp#parte"), "name": "S3 frag"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); // Parts answered with a web page are done without, and said; a .dbf answered so is refused. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/spa/rios.shp"), "name": "SPA"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.notices.last().unwrap().text.contains("no .prj, .cpg found or readable beside it"), "{:?}", s.notices.last()); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/spa/sem_dbf.shp")})).unwrap_err(); assert!(e.contains("/spa/sem_dbf.dbf: a web page answered, not a .dbf"), "{e}"); // A page answering for the .shp itself fails at once, its parts not asked for. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/spa/pagina.shp")})).unwrap_err(); assert!(e.contains("/spa/pagina.shp: a web page answered, not a .shp"), "{e}"); assert!(!asked.lock().unwrap().iter().any(|a| a.starts_with("/spa/pagina.dbf"))); // A keyed address is named once, without its token. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/spa/tk.shp?token=1")})).unwrap_err(); assert!(e.matches("/spa/tk.dbf").count() == 1 && e.contains("a web page answered") && !e.contains("token=1"), "{e}"); // One whose parts download but don't read is named without its token too. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/bad/tk.shp?token=1")})).unwrap_err(); assert!(e.contains("/bad/tk.shp") && !e.contains("token=1"), "{e}"); // An address refused before anything is fetched is named without its token too. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/a.tif?token=1"), "cellSize": 10})).unwrap_err(); assert!(e.contains("\"cellSize\" goes with \"bbox\"") && !e.contains("token=1"), "{e}"); // Its .dbf answered so is refused, saying what the 403 may mean. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/s3/sem_dbf.shp")})).unwrap_err(); assert!(e.contains("/s3/sem_dbf.dbf") && e.contains("S3 also answers this for a file that isn't there"), "{e}"); // An address answering with the parts zipped is read as it is, its parts not asked for. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/zipped/rios.shp"), "name": "Zipado"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(!asked.lock().unwrap().iter().any(|a| a.starts_with("/zipped/rios.dbf"))); // With `layer`, the notice speaks of the Shapefile added only. let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{base}/zipped/dois.shp"), "layer": "a", "name": "So a"})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("optional files")), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{base}/zipped/dois.shp"), "layer": "b", "name": "So b"})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("b.shp: no .prj")), "{:?}", s.notices_since(mark)); // Without its .shx, it is read, and the notice names the .shx. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/noshx/rios.shp"), "name": "Sem shx"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.notices.last().unwrap().text.contains("no .shx found or readable beside it (1 of 3"), "{:?}", s.notices.last()); // Without its .dbf, it is refused, naming the .dbf's address. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/nodbf/rios.shp")})).unwrap_err(); assert!(e.contains("/nodbf/rios.dbf") && e.contains("404"), "{e}"); // Left to the app, it is asked for as it was given; the app's download brings the parts. let url = format!("{base}/PI/TERESINA/HIDROGRAFIA/PI_2211001_RIOS.shp?token=1"); s.leave_downloads = true; assert_eq!(s.run("layer.addUrl", &json!({"url": url, "name": "Pela janela"})).unwrap()["pending"], true); s.leave_downloads = false; assert_eq!(s.take_url_requests()[0].params()["url"], json!(url)); // The app's download, waited for here (failing rather than hanging past 30 s). let fetch = |url: &str, max: usize| { let slot = std::sync::Arc::new(std::sync::Mutex::new(None)); let land = slot.clone(); crate::cmd::layer::download_data(url, max, Box::new(move |r| *land.lock().unwrap() = Some(r))); let until = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { if let Some(r) = slot.lock().unwrap().take() { break r; } assert!(std::time::Instant::now() < until, "{url}: the download didn't land"); std::thread::sleep(std::time::Duration::from_millis(20)); } }; // The app's download of a signed .shp refused says what the 403 may mean, without the signature. let e = fetch(&format!("{base}/signed/gone.shp?sig=1"), 1 << 20).unwrap_err(); assert!(e.contains("403") && e.contains("may also mean the file isn't there") && !e.contains("sig=1"), "{e}"); // A .shp address answering with a zip lands as it is, its parts not asked for. let zipped = fetch(&format!("{base}/zipped/rios.shp"), 1 << 20).unwrap(); assert!(zipped.starts_with(b"PK\x03\x04")); assert!(!asked.lock().unwrap().iter().any(|a| a.starts_with("/zipped/rios.dbf"))); let id = s.download_started(); assert!(s.download_landed(id, fetch(&url, 1 << 20).unwrap())); let mark = s.notice_mark(); let r = s.run("layer.addDownload", &json!({"id": id, "url": url, "name": "Pela janela"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.map().layer_by_name("Pela janela").is_some()); let said = s.notices_since(mark); assert!(!said.iter().any(|n| n.text.contains("optional files")), "{said:?}"); // Through the app too, what was left out is said. let plain = format!("{base}/plain/rios.shp"); let id = s.download_started(); assert!(s.download_landed(id, fetch(&plain, 1 << 20).unwrap())); let mark = s.notice_mark(); s.run("layer.addDownload", &json!({"id": id, "url": plain, "name": "Sem prj pela janela"})).unwrap(); let said = s.notices_since(mark); assert!(said.iter().any(|n| n.text.contains("no .prj, .cpg found or readable beside it (2 of 3")), "{said:?}"); // A download too large for the budget together is refused: the .shp and .dbf land, and the // .shx goes over the 2 bytes left, named without the address's token. let e = fetch(&url, 200).unwrap_err(); assert!( e.contains("/PI_2211001_RIOS.shx") && e.ends_with(": larger than 2 bytes, what the .shp and the parts before it left of the download limit") && !e.contains("token=1"), "{e}" ); // Within the parts' own size, the zip's records go over. let exact: usize = parts.iter().map(|(_, b)| b.len()).sum(); assert_eq!(fetch(&url, exact).unwrap_err(), format!("larger than {exact} bytes, zipped")); // The app's parts too: a page answering for a part leaves it out, and a keyed part refused // fails, named without its signature. assert_eq!(fetch(&format!("{base}/spa/pagina.shp"), 1 << 20).unwrap_err(), "a web page answered, not a .shp"); let zip = fetch(&format!("{base}/spa/rios.shp"), 1 << 20).unwrap(); assert_eq!(rustgis_data::io::shapefile_zip_lacks(&zip), [("rios.shp".to_string(), vec!["prj", "cpg"])]); let e = fetch(&format!("{base}/signed/rios.shp?sig=1"), 1 << 20).unwrap_err(); assert!(e.contains("/signed/rios.prj") && e.contains("403") && !e.contains("sig=1"), "{e}"); } /// Data added by its web address: the format from the address or, without one, the contents; a /// page that isn't data, a missing file and a forbidden address are refused. #[test] fn data_from_a_web_address() { const GEOJSON: &[u8] = br#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [10, 20]}}]}"#; // Five requests: an address without a file name is asked for its headers and its first // bytes (is it a GeoTIFF?) before it is downloaded. let base = serve(GEOJSON, 5); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/places.geojson")})).unwrap(); assert_eq!(r["features"], 1); assert!(s.map().layer_by_name("places").is_some()); // An API address without an extension: the contents say GeoJSON. s.run("layer.addUrl", &json!({"url": format!("{base}/api/items?f=json"), "name": "Api"})).unwrap(); assert!(s.map().layer_by_name("Api").is_some()); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/missing.geojson")})).unwrap_err(); assert!(e.contains("404"), "{e}"); let page = serve(b"not data", 3); let e = s.run("layer.addUrl", &json!({"url": format!("{page}/index")})).unwrap_err(); assert!(e.contains("not a data format"), "{e}"); for bad in ["file:///etc/passwd", "/tmp/x.geojson", "http://169.254.169.254/latest.geojson", ""] { assert!(s.run("layer.addUrl", &json!({"url": bad})).is_err(), "{bad}"); } assert_eq!(crate::cmd::layer::url_file_name("https://x.org/a/b/roads.parquet?sig=1", b"PAR1").unwrap(), "roads.parquet"); assert_eq!(crate::cmd::layer::url_file_name("https://x.org/a/b/", b"PAR1").unwrap(), "b.parquet"); assert_eq!(crate::cmd::layer::url_file_name("https://x.org", b"lon,lat\n1,2").unwrap(), "data.csv"); assert_eq!(crate::cmd::layer::url_file_name("https://x.org/q.json", b"[]").unwrap(), "q.geojson"); // A CityJSON model isn't taken for GeoJSON, by its name or its contents. let model = br#"{"type":"CityJSON","CityObjects":{}}"#; assert_eq!(crate::cmd::layer::url_file_name("https://x.org/m.city.json", b"{}").unwrap(), "m.city.json"); assert_eq!(crate::cmd::layer::url_file_name("https://x.org/q.json", model).unwrap(), "q.city.json"); assert_eq!(crate::cmd::layer::url_file_name("https://x.org", model).unwrap(), "data.city.json"); } /// JPEG 2000 from a web address or uploaded bytes is read through GDAL on the desktop, as a file /// on disk is; contents that aren't JPEG 2000 never reach GDAL. #[cfg(not(target_arch = "wasm32"))] #[test] fn jpeg2000_from_a_web_address() { use base64::Engine; let mut s = Session::new(Project::default()); // A VRT (which would have GDAL read other files on this computer) named like JPEG 2000. let vrt = serve(br#""#, 1); let e = s.run("layer.addUrl", &json!({"url": format!("{vrt}/TCI.jp2")})).unwrap_err(); assert!(e.contains("not a JPEG 2000 file"), "{e}"); // Without a file name in the address, the contents alone say JPEG 2000 (a JP2 box or a bare // codestream); the same bytes are refused where GDAL can't run. A VRT stays an unknown format. let jp2_box = b"\0\0\0\x0cjP \r\n\x87\n"; let codestream = [0xff, 0x4f, 0xff, 0x51, 0x00]; let named = |url: &str, bytes: &[u8]| crate::cmd::layer::url_file_name(url, bytes); assert_eq!(named("https://x.org/api/tile?f=jp2", jp2_box).unwrap(), "tile.jp2"); assert_eq!(named("https://x.org/", &codestream).unwrap(), "data.j2k"); assert!(named("https://x.org/api/tile", br#""#).unwrap_err().contains("not a data format")); if !rustgis_data::raster::gdal::available() { let jp2 = serve(jp2_box, 4); for path in ["TCI.jp2", "api/tile"] { let e = s.run("layer.addUrl", &json!({"url": format!("{jp2}/{path}")})).unwrap_err(); assert!(e.contains("needs GDAL"), "{path}: {e}"); } return; } let executable = if cfg!(windows) { "gdal_translate.exe" } else { "gdal_translate" }; let translate = std::env::var_os("RUSTGIS_GDAL_BIN") .map(|d| std::path::PathBuf::from(d).join(executable)) .filter(|p| p.is_file()) .unwrap_or_else(|| executable.into()); let formats = std::process::Command::new(&translate).arg("--formats").output().expect("list GDAL drivers"); assert!(formats.status.success(), "GDAL driver discovery failed: {}", String::from_utf8_lossy(&formats.stderr)); if !String::from_utf8_lossy(&formats.stdout).lines().any(|line| line.split_whitespace().next() == Some("JP2OpenJPEG")) { eprintln!("skipping JPEG 2000 conversion: GDAL has no optional JP2OpenJPEG driver"); return; } let dir = std::env::temp_dir().join(format!("rustgis-engine-jp2-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let asc = dir.join("g.asc"); std::fs::write(&asc, "ncols 4\nnrows 3\nxllcorner 10\nyllcorner 40\ncellsize 0.5\n1 2 3 4\n5 6 7 8\n9 10 11 12\n").unwrap(); let jp2 = dir.join("g.jp2"); let made = std::process::Command::new(&translate) .args(["-q", "-of", "JP2OpenJPEG", "-ot", "Byte", "-a_srs", "EPSG:4326"]) .arg(&asc) .arg(&jp2) .output() .expect("generate JPEG 2000 fixture"); assert!(made.status.success(), "JPEG 2000 fixture conversion failed ({}): {}", made.status, String::from_utf8_lossy(&made.stderr)); // `serve` requires a 'static slice; intentionally leak this small test-only fixture so it // stays valid for the server thread's lifetime. let bytes = Vec::leak(std::fs::read(&jp2).unwrap()); let uploaded = base64::engine::general_purpose::STANDARD.encode(&*bytes); let base = serve(bytes, 4); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/tiles/33/T/XN/TCI.jp2")})).unwrap(); let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(l.name, "TCI"); let rl = l.raster.as_ref().unwrap(); let data = rl.data.as_ref().unwrap(); assert_eq!((data.width, data.height, data.georeferenced), (4, 3, true)); let e = data.extent(); assert!((e.min_x - 10.0).abs() < 1e-6 && (e.max_y - 41.5).abs() < 1e-6, "{e:?}"); // The project keeps the converted GeoTIFF, so it opens again without GDAL. assert_eq!(rl.file_name.as_deref(), Some("TCI.tif")); assert!(rl.embedded.is_some() && rl.path.is_none()); // The same file from an endpoint with no extension: named by its contents, converted alike. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/api/tile")})).unwrap(); let rl = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap(); let raster = rl.data.as_ref().unwrap(); assert_eq!((raster.width, raster.height, raster.georeferenced), (4, 3, true)); assert_eq!(rl.file_name.as_deref(), Some("tile.tif")); assert!(rl.embedded.is_some() && rl.path.is_none()); for file_name in ["uploaded.jp2", "uploaded.J2K"] { let mut upload = Session::new(Project::default()); let r = upload.run("layer.addDataBytes", &json!({"fileName": file_name, "data": uploaded})).unwrap(); let rl = upload.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap(); let raster = rl.data.as_ref().unwrap(); assert_eq!((raster.width, raster.height, raster.georeferenced), (4, 3, true)); let e = raster.extent(); assert!((e.min_x - 10.0).abs() < 1e-6 && (e.max_y - 41.5).abs() < 1e-6, "{e:?}"); assert_eq!(rl.file_name.as_deref(), Some("uploaded.tif")); assert!(rl.embedded.is_some() && rl.path.is_none()); } let _ = std::fs::remove_dir_all(dir); } /// Where a command may not download itself (the browser), `layer.addUrl` leaves its request for /// the front end: once per request, a few at a time (the next is refused, never one silently /// dropped after it was promised), and taken once. #[test] fn url_requests_wait_for_the_front_end() { let mut s = Session::new(Project::default()); let p = json!({"url": "https://x.org/a.geojson", "name": "A", "zoom": true}); assert_eq!(s.request_url(&p), Ok(json!({"pending": true, "url": "https://x.org/a.geojson"}))); assert!(s.request_url(&p).is_ok()); assert!(s.request_url(&json!({"url": "https://x.org/b.geojson"})).is_ok()); // The same address read another way is another layer. assert!(s.request_url(&json!({"url": "https://x.org/a.geojson", "name": "A2"})).is_ok()); let taken = s.take_url_requests(); assert_eq!(taken.len(), 3, "the same request once"); assert_eq!((taken[0].params()["name"].as_str(), taken[0].params()["zoom"].as_bool()), (Some("A"), Some(true))); assert_eq!(taken[1].params()["url"], "https://x.org/b.geojson"); assert_eq!((taken[2].params()["url"].as_str(), taken[2].params()["name"].as_str()), (Some("https://x.org/a.geojson"), Some("A2"))); assert!(s.take_url_requests().is_empty()); drop(taken); // Abandoned opaque requests release their bounded unclaimed authority. for i in 0..16 { assert!(s.request_url(&json!({"url": format!("https://x.org/{i}")})).is_ok(), "{i}"); } let refused = s.request_url(&json!({"url": "https://x.org/16"})).unwrap_err(); assert!(refused.contains("16 downloads are already waiting"), "{refused}"); assert!(s.request_url(&json!({"url": "https://x.org/3"})).is_ok(), "one already waiting is fine"); let taken = s.take_url_requests(); assert_eq!(taken.len(), 16, "a few at a time"); assert_eq!( (taken[0].params()["url"].as_str(), taken[15].params()["url"].as_str()), (Some("https://x.org/0"), Some("https://x.org/15")) ); drop(taken); assert!(s.request_url(&json!({"url": "https://x.org/16"})).is_ok(), "room again once abandoned"); } #[test] fn url_validation_keeps_its_reason_without_unescaped_query_tails() { let mut s = Session::new(Project::default()); for suffix in ["?token=hidden hidden-tail", "?%2574oken=hidden hidden-tail", "?custom=hidden hidden-tail", "#hidden hidden-tail"] { let url = format!("https://validation.example/data.tif{suffix}"); let mark = s.notice_mark(); let error = s.run("layer.addUrl", &json!({"url":url, "cellSize":10})).unwrap_err(); assert!(error.contains("\"cellSize\" goes with \"bbox\""), "{error}"); assert!(!error.contains("hidden"), "{error}"); let notice = s.notices_since(mark).last().unwrap().text.clone(); assert!(notice.contains("\"cellSize\" goes with \"bbox\"") && !notice.contains("hidden"), "{notice}"); let clean = Session::redacted(&Session::redacted(&error).into_string()); assert!(clean.as_str().contains("\"cellSize\" goes with \"bbox\"") && !clean.as_str().contains("hidden"), "{clean:?}"); } let report = s.diagnostics.report(rustgis_data::diagnostics::Filter::All); assert!(report.contains("\"cellSize\" goes with \"bbox\"") && !report.contains("hidden"), "{report}"); } #[test] fn full_download_queue_keeps_its_reason_through_session_notices() { let mut s = Session::new(Project::default()); for i in 0..16 { s.request_url(&json!({"url": format!("https://queue.example/{i}")})).unwrap(); } for suffix in ["?token=queue-secret", "#queue-secret", "?token=queue-secret secret-tail", "#queue-secret secret-tail"] { let url = format!("https://queue.example/refused.geojson{suffix}"); let mark = s.notice_mark(); let error = s.guarded("Add Data From URL", true, |s| s.request_url(&json!({"url": url}))).unwrap_err(); assert!(error.contains("16 downloads are already waiting; try again when they have landed"), "{error}"); assert!(!error.contains("queue-secret") && !error.contains("secret-tail"), "{error}"); assert_eq!(Session::redacted(&error).as_str(), error); assert_eq!(s.notices_since(mark).iter().map(|n| n.text.as_str()).collect::>(), [error.as_str()]); s.notify(error.clone(), true); assert_eq!(s.notices_since(mark).last().unwrap().text, error); assert!(s.diagnostics.records().iter().any(|r| r.message == error)); } let report = s.diagnostics.report(rustgis_data::diagnostics::Filter::All); assert!(!report.contains("queue-secret") && !report.contains("secret-tail"), "{report}"); } /// A mutating command that only left a request for the front end (the browser's `layer.addUrl`) /// leaves no undo step and nothing to save; one that changed the project still does, and so /// does one that merely answers "pending" (a plugin's command can't claim it changed nothing). #[test] fn a_pending_answer_is_no_undo_step() { let mut s = Session::new(Project::default()); let undo = s.undo.len(); let p = json!({"url": "https://x.org/a.geojson"}); let r = s.guarded("Add Data From URL", true, |s| s.request_url(&p)); assert_eq!(r.unwrap()["pending"], true); assert_eq!(s.undo.len(), undo); assert!(!s.dirty()); assert_eq!(s.take_url_requests().len(), 1); let r = s.guarded("Plugin Command", true, |_| Ok(json!({"pending": true}))); assert!(r.is_ok()); assert_eq!(s.undo.len(), undo + 1, "an answer alone doesn't skip the step"); assert!(s.dirty()); assert!(s.run("map.new", &json!({"name": "B"})).is_ok()); assert_eq!(s.undo.len(), undo + 2); // A command that changed the project and then, through a command of its own, left a // request (a plugin's) gets its step: the inner run takes the flag, the outer never sees it. let r = s.guarded("Plugin Command", true, |s| { s.run("map.new", &json!({"name": "C"}))?; s.guarded("Add Data From URL", true, |s| s.request_url(&json!({"url": "https://x.org/c.geojson"}))) }); assert_eq!(r.unwrap()["pending"], true); assert_eq!(s.undo.len(), undo + 4, "the map's step and the plugin's"); assert_eq!(s.take_url_requests().len(), 1); } /// A command that left a request for the front end and then failed takes the request back with /// its other changes: nothing is downloaded for a command that reported failure. #[test] fn a_failed_command_takes_its_request_back() { let mut s = Session::new(Project::default()); assert!(s.request_url(&json!({"url": "https://x.org/kept.geojson"})).is_ok()); let r = s.guarded("Plugin Command", true, |s| { s.guarded("Add Data From URL", true, |s| s.request_url(&json!({"url": "https://x.org/dropped.geojson"})))?; Err("the plugin failed afterwards".to_string()) }); assert!(r.is_err()); let left = s.take_url_requests(); assert_eq!(left.len(), 1); assert_eq!(left[0].params()["url"], "https://x.org/kept.geojson"); } /// A service's features keep the address they came from, and Copy Path gives it without its key. #[test] fn a_service_layers_address_is_copied_without_its_key() { let mut s = Session::new(Project::default()); let id = s.download_started(); assert!(s.download_landed(id, br#"{"type": "Point", "coordinates": [1, 2]}"#.to_vec())); let url = "https://x.org/collections/a/items?f=json&api_key=SECRET"; let r = s.run("layer.addDownload", &json!({"id": id, "url": url, "name": "Api", "service": true})).unwrap(); let layer = r["layer"].as_u64().or_else(|| r["layers"][0].as_u64()).unwrap() as u32; assert_eq!(s.map().layer(layer).and_then(|l| l.dataset.source.as_deref()), Some(url)); let mark = s.notice_mark(); let copied = s.run("layer.copyPath", &json!({"layer": layer})).unwrap(); let text = copied["text"].as_str().unwrap(); assert!(text.starts_with("https://x.org/collections/a/items?") && !text.contains("SECRET"), "{text}"); let said = |s: &Session| s.notices_since(mark).iter().filter(|n| n.text.contains("leaves out part")).count(); assert_eq!(said(&s), 1, "the copied address says what it left out"); // An address with nothing secret in it is copied as it is, with no notice. let id = s.download_started(); assert!(s.download_landed(id, br#"{"type": "Point", "coordinates": [1, 2]}"#.to_vec())); let plain = "https://x.org/collections/b/items?f=json"; let r = s.run("layer.addDownload", &json!({"id": id, "url": plain, "name": "Plain", "service": true})).unwrap(); let layer = r["layer"].as_u64().or_else(|| r["layers"][0].as_u64()).unwrap() as u32; let mark = s.notice_mark(); let copied = s.run("layer.copyPath", &json!({"layer": layer})).unwrap(); assert_eq!(copied["text"], plain); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("leaves out")), "{:?}", s.notices_since(mark)); } /// The app's downloads land in the project they were started for, as bytes (no base64); one /// whose project was closed meanwhile is dropped. #[test] fn downloads_land_in_their_own_project() { let mut s = Session::new(Project::default()); let id = s.download_started(); assert!(s.download_landed(id, br#"{"type": "Point", "coordinates": [1, 2]}"#.to_vec())); let r = s.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/api?f=json", "name": "Api"})).unwrap(); assert_eq!(r["features"], 1); assert!(s.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/a.geojson"})).is_err(), "used once"); let late = s.download_started(); s.run("project.new", &json!({})).unwrap(); let notice_mark = s.notice_mark(); assert!(!s.download_landed(late, b"{}".to_vec()), "another project is open now"); assert!(s.notices_since(notice_mark).is_empty(), "a normal dropped download leaves its informational notice to the UI"); let failed = s.download_started(); s.download_failed(failed); let notice_mark = s.notice_mark(); assert!(!s.download_landed(failed, b"{}".to_vec())); assert!(s.notices_since(notice_mark).is_empty(), "an already failed request is not a new delivery error"); // A link's first file zooms the map to it, whatever the map held. let mut w = world(); let id = w.download_started(); assert!(w.download_landed(id, br#"{"type": "Point", "coordinates": [151.2, -33.9]}"#.to_vec())); w.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/sydney.geojson", "zoom": true})).unwrap(); let c = rustgis_geom::merc_to_lonlat(w.map().camera.center); assert!((c.x - 151.2).abs() < 0.5 && (c.y + 33.9).abs() < 0.5, "{c:?}"); // A file of several layers (a GPX's waypoints and track): the map frames them all. let gpx = br#""#; let id = w.download_started(); assert!(w.download_landed(id, gpx.to_vec())); let r = w.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/trip.gpx", "zoom": true})).unwrap(); assert_eq!(r["zoomed"], true, "{r}"); let c = rustgis_geom::merc_to_lonlat(w.map().camera.center); assert!(c.x > 15.0 && c.x < 26.0 && c.y > 10.0 && c.y < 21.0, "{c:?}"); // Without "zoom" the map stays where it is. let id = w.download_started(); assert!(w.download_landed(id, br#"{"type": "Point", "coordinates": [-70, -40]}"#.to_vec())); let r = w.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/far.geojson"})).unwrap(); assert!(r.get("zoomed").is_none()); assert_eq!(rustgis_geom::merc_to_lonlat(w.map().camera.center), c); } /// `"zoom": false` keeps the view of a downloaded layer even when it is the map's first content; /// without it the first content is framed. A whole GeoTIFF downloaded from an address follows the /// same rule as one read in place, through `layer.addUrl` and the app's `layer.addDownload` alike. #[test] fn zoom_false_keeps_the_view_of_downloaded_data() { let far = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}}]}"#; let (base, _) = serve_file(far.as_bytes().to_vec(), false); // A GeoJSON downloaded from an address on an empty map: "zoom": false keeps the view. let mut s = Session::new(Project::default()); let start = s.map().camera; let r = s.run("layer.addUrl", &json!({"url": format!("{base}/sydney.geojson"), "zoom": false})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert_eq!(s.map().camera, start); // Without "zoom" the first content on an empty map is framed, as before. let mut f = Session::new(Project::default()); f.run("layer.addUrl", &json!({"url": format!("{base}/sydney.geojson")})).unwrap(); let at = f.map().center_lonlat(); assert!((at.x - 151.2).abs() < 0.1 && (at.y + 33.9).abs() < 0.5, "{at:?}"); // The app's download lands and is added (layer.addDownload): the same rule holds. let mut a = Session::new(Project::default()); let id = a.download_started(); assert!(a.download_landed(id, far.as_bytes().to_vec())); let start = a.map().camera; a.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/sydney.geojson", "zoom": false})).unwrap(); assert_eq!(a.map().camera, start); // A whole GeoTIFF (strips, not cloud-optimised) is downloaded and added as a raster layer; // "zoom": false keeps the view when it is the first content too. let raster = rustgis_data::raster::Raster::new( "strips", 8, 8, vec![vec![3.0; 64]], rustgis_data::raster::GeoTransform { origin_x: 5.0, origin_y: 50.0, pixel_width: 0.1, pixel_height: -0.1 }, rustgis_data::raster::Crs::LonLat, None, rustgis_data::raster::ColorHint::Gray, "Float32", ) .unwrap(); let bytes = rustgis_data::raster::geotiff::write(&raster, 0).unwrap(); let (tif, _) = serve_ranges(bytes.clone()); let mut z = Session::new(Project::default()); let start = z.map().camera; let r = z.run("layer.addUrl", &json!({"url": format!("{tif}/strips.tif"), "zoom": false})).unwrap(); assert!(z.map().layer(r["layer"].as_u64().unwrap() as u32).is_some_and(|l| l.is_raster()), "{r}"); assert_eq!(z.map().camera, start); // Through the app's download path as well. let mut w = Session::new(Project::default()); let id = w.download_started(); assert!(w.download_landed(id, bytes)); let start = w.map().camera; w.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/strips.tif", "zoom": false})).unwrap(); assert_eq!(w.map().camera, start); // A FlatGeobuf downloaded whole (not read in place): same rule through the source reader. let fgb = std::fs::read(grid_fgb("zoom-false", 5)).unwrap(); let (fgb_base, _) = serve_file(fgb, false); let mut v = Session::new(Project::default()); let start = v.map().camera; v.run("layer.addUrl", &json!({"url": format!("{fgb_base}/grid.fgb"), "zoom": false})).unwrap(); assert_eq!(v.map().camera, start); // A downloaded KMZ of ground overlays only: its raster layers follow the same rule. let mut red = tiny_skia::Pixmap::new(8, 8).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let doc = r#"Overfiles/red.png41401110"#; let kmz = rustgis_data::zip::write(&[("doc.kml".into(), doc.as_bytes().to_vec()), ("files/red.png".into(), red.encode_png().unwrap())]) .unwrap(); let (kmz_base, _) = serve_file(kmz, false); let mut o = Session::new(Project::default()); let start = o.map().camera; o.run("layer.addUrl", &json!({"url": format!("{kmz_base}/site.kmz"), "zoom": false})).unwrap(); assert!(o.map().layer_by_name("Over").is_some()); assert_eq!(o.map().camera, start); } /// `"zoom": false` keeps the view when a spreadsheet or a geotagged photo is added to an empty map; /// without it the first content is framed, and a later photo, like any layer, leaves the view alone. #[test] fn zoom_false_keeps_the_view_of_sheets_and_photos() { use base64::Engine; let book = rustgis_data::sheets::xlsx(&[("Places", &[&["name", "longitude", "latitude"], &["A", "10", "20"]])]); let b64 = base64::engine::general_purpose::STANDARD.encode(&book); let mut s = Session::new(Project::default()); let start = s.map().camera; let r = s .run("layer.addDataBytes", &json!({"fileName": "places.xlsx", "data": b64, "x": "longitude", "y": "latitude", "zoom": false})) .unwrap(); assert!(s.map().layer(r["layer"].as_u64().unwrap() as u32).is_some(), "{r}"); assert_eq!(s.map().camera, start); let mut f = Session::new(Project::default()); f.run("layer.addDataBytes", &json!({"fileName": "places.xlsx", "data": b64, "x": "longitude", "y": "latitude"})).unwrap(); let c = rustgis_geom::merc_to_lonlat(f.map().camera.center); assert!((c.x - 10.0).abs() < 0.01 && (c.y - 20.0).abs() < 0.01, "{c:?}"); // A geotagged photo downloaded from an address: the same rule. let (base, _) = serve_file(rustgis_data::photo::example(true, (43.4674, "N"), (11.8851, "E")), false); let mut ph = Session::new(Project::default()); let start = ph.map().camera; let r = ph.run("layer.addUrl", &json!({"url": format!("{base}/pic.jpg"), "zoom": false})).unwrap(); assert_eq!(r["photos"], 1, "{r}"); assert_eq!(ph.map().camera, start); let mut ph2 = Session::new(Project::default()); ph2.run("layer.addUrl", &json!({"url": format!("{base}/pic.jpg")})).unwrap(); let c = rustgis_geom::merc_to_lonlat(ph2.map().camera.center); assert!((c.x - 11.8851).abs() < 0.01 && (c.y - 43.4674).abs() < 0.01, "{c:?}"); // A later photo leaves the view alone, as a later layer does; `"zoom": true` frames it. let far = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}}]}"#; let mut ph3 = Session::new(Project::default()); ph3.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); ph3.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = ph3.map().camera; ph3.run("layer.addUrl", &json!({"url": format!("{base}/pic.jpg")})).unwrap(); assert_eq!(ph3.map().camera, before); ph3.run("layer.addUrl", &json!({"url": format!("{base}/pic.jpg"), "zoom": true})).unwrap(); let c = rustgis_geom::merc_to_lonlat(ph3.map().camera.center); assert!((c.x - 11.8851).abs() < 0.01 && (c.y - 43.4674).abs() < 0.01, "{c:?}"); // The app's download of a photo follows the same rule: `"zoom": false` keeps the view. let mut ph4 = Session::new(Project::default()); let start = ph4.map().camera; let id = ph4.download_started(); assert!(ph4.download_landed(id, rustgis_data::photo::example(true, (43.4674, "N"), (11.8851, "E")))); let r = ph4.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/pic.jpg", "zoom": false})).unwrap(); assert_eq!(r["photos"], 1, "{r}"); assert_eq!(ph4.map().camera, start); // `"zoom": true` frames only the photos the download added: the layer holds an earlier, // far-away photo too, which must not pull the view back to it. let mut ph5 = Session::new(Project::default()); let id = ph5.download_started(); assert!(ph5.download_landed(id, rustgis_data::photo::example(true, (43.4674, "N"), (11.8851, "E")))); ph5.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/pic.jpg"})).unwrap(); ph5.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let id = ph5.download_started(); assert!(ph5.download_landed(id, rustgis_data::photo::example(true, (48.8566, "N"), (2.3522, "E")))); let r = ph5.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/pic2.jpg", "zoom": true})).unwrap(); assert_eq!(r["zoomed"], true, "{r}"); let c = rustgis_geom::merc_to_lonlat(ph5.map().camera.center); assert!((c.x - 2.3522).abs() < 0.05 && (c.y - 48.8566).abs() < 0.05, "{c:?}"); } /// `"zoom": true` frames everything a multi-layer file added, not only its last layer: the native /// `layer.addUrl` path as the app's `layer.addDownload` does with its own union. #[test] fn zoom_true_frames_every_layer_a_file_added() { let gpx = br#""#; let (base, _) = serve_file(gpx.to_vec(), false); let mut s = world(); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/trip.gpx"), "zoom": true})).unwrap(); assert!(r["layers"].as_array().is_some_and(|l| l.len() >= 2), "{r}"); let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); assert!(c.x > 15.0 && c.x < 26.0 && c.y > 10.0 && c.y < 21.0, "{c:?}"); } /// Spreadsheets, GeoRSS, LandXML and encoded polylines through the commands. #[test] fn spreadsheets_feeds_landxml_and_polylines() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-more-formats-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); // A workbook: the first sheet by default, another by "layer", with a coordinate system. let book = rustgis_data::sheets::xlsx(&[ ("Places", &[&["name", "longitude", "latitude"], &["A", "10", "20"]]), ("Survey", &[&["id", "E", "N"], &["1", "992000", "220000"]]), ]); let path = dir.join("book.xlsx"); std::fs::write(&path, &book).unwrap(); let p = path.display().to_string(); assert_eq!(s.run("info.fileLayers", &json!({"path": p})).unwrap()["layers"], json!(["Places", "Survey"])); let r = s.run("layer.addData", &json!({"path": p})).unwrap(); assert_eq!(r["features"], 1); let b64 = base64::engine::general_purpose::STANDARD.encode(&book); let r = s .run( "layer.addDataBytes", &json!({"fileName": "book.xlsx", "data": b64, "layer": "survey", "x": "E", "y": "N", "crs": "EPSG:2263"}), ) .unwrap(); let e = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().extent(); assert!((e.min_x + 73.96).abs() < 0.05, "{e:?}"); assert!(s.run("layer.addData", &json!({"path": p, "layer": "nope"})).is_err()); // Its columns, and a delimited file's, for Add Delimited Text. let c = s.run("info.fileColumns", &json!({"path": p, "layer": "Survey"})).unwrap(); assert_eq!(c, json!({"columns": ["id", "E", "N"], "x": null, "y": null, "wkt": null, "delimiter": null})); let csv = dir.join("stops.csv"); std::fs::write(&csv, "stop;Lon;Lat\nA;1;2\n").unwrap(); let csv = csv.display().to_string(); let c = s.run("info.fileColumns", &json!({"path": csv})).unwrap(); assert_eq!(c, json!({"columns": ["stop", "Lon", "Lat"], "x": "Lon", "y": "Lat", "wkt": null, "delimiter": ";"})); let c = s.run("info.fileColumns", &json!({"path": csv, "delimiter": ","})).unwrap(); assert_eq!(c["columns"], json!(["stop;Lon;Lat"])); assert!(s.run("info.fileColumns", &json!({"path": csv, "delimiter": ";;"})).is_err()); assert!(s.run("info.fileColumns", &json!({"path": dir.join("missing.csv").display().to_string()})).is_err()); // Only the start of delimited text is read: a character cut at its end isn't an error, and // bytes that aren't UTF-8 are read as Latin-1 (#1303), as the file is. let cut = crate::cmd::layer::columns_of("t.csv", b"lon,lat,name\n1,2,\xc3", &json!({})).unwrap(); assert_eq!(cut["x"], "lon"); let latin1 = crate::cmd::layer::columns_of("t.csv", b"lon;lat;munic\xedpio\n\xff;2;x\n", &json!({})).unwrap(); assert_eq!(latin1["columns"], json!(["lon", "lat", "município"])); // A Latin-1 file with its x/y columns named, as a public source's CSV is added: read, with // a notice saying how. let cnes = dir.join("cnes.csv"); std::fs::write(&cnes, b"NO_FANTASIA;NU_LATITUDE;NU_LONGITUDE\nPOSTO S\xc3O JOS\xc9;-5.09;-42.80\n").unwrap(); s.notices.clear(); let r = s .run("layer.addData", &json!({"path": cnes.display().to_string(), "x": "NU_LONGITUDE", "y": "NU_LATITUDE", "delimiter": ";"})) .unwrap(); let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(l.dataset.value(0, 0), &rustgis_data::Value::Text("POSTO SÃO JOSÉ".into())); assert!(s.notices.iter().any(|n| n.text.contains("Windows-1252")), "{:?}", s.notices); // A GeoRSS feed as plain .xml, by its contents. let feed = dir.join("quakes.xml"); std::fs::write(&feed, r#"Q45 -110"#).unwrap(); s.run("layer.addData", &json!({"path": feed.display().to_string()})).unwrap(); assert!(s.map().layer_by_name("quakes points").is_some()); // LandXML without a coordinate system, given one. let lx = dir.join("site.landxml"); std::fs::write(&lx, r#"4649776 500000"#).unwrap(); s.run("layer.addData", &json!({"path": lx.display().to_string(), "crs": "EPSG:32633"})).unwrap(); let e = s.map().layer_by_name("site points").unwrap().extent(); assert!((e.min_x - 15.0).abs() < 1e-6, "{e:?}"); // Encoded polylines. let r = s .run("layer.addEncodedPolyline", &json!({"polyline": ["_p~iF~ps|U_ulLnnqC", "_p~iF~ps|U_ulLnnqC_mqNvxq`@"], "name": "Routes"})) .unwrap(); assert_eq!(r["features"], 2); for bad in [json!({}), json!({"polyline": "_p~iF"}), json!({"polyline": "_p~iF~ps|U"}), json!({"polyline": "x", "precision": 99})] { assert!(s.run("layer.addEncodedPolyline", &bad).is_err(), "{bad}"); } let _ = std::fs::remove_dir_all(dir); } /// A raster GDAL reads, added with a coordinate system: the project names the file and the /// choice, and opens it again after the conversion cache is cleared. (Runs where GDAL is.) #[cfg(not(target_arch = "wasm32"))] #[test] fn gdal_rasters_reopen_with_their_choices() { // Found as the app finds it (the PATH, or RUSTGIS_GDAL_BIN). let Some(translate) = rustgis_data::source::gdal::program("gdal_translate") else { return }; let dir = std::env::temp_dir().join(format!("rustgis-gdal-reopen-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let asc = dir.join("g.asc"); // UTM 33N coordinates and no .prj: the file doesn't say its system. std::fs::write(&asc, "ncols 2\nnrows 2\nxllcorner 500000\nyllcorner 4649776\ncellsize 100\n1 2\n3 4\n").unwrap(); let img = dir.join("g.img"); if !std::process::Command::new(&translate).args(["-q", "-of", "HFA"]).arg(&asc).arg(&img).status().is_ok_and(|s| s.success()) { return; } let mut s = Session::new(Project::default()); let p = img.display().to_string(); assert!(s.run("layer.addData", &json!({"path": p})).is_err(), "no coordinate system, not lon/lat"); s.run("layer.addData", &json!({"path": p, "crs": "EPSG:32633"})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("\"crs\":\"EPSG:32633\""), "the choice is saved"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = t.map().layers.iter().find(|l| l.is_raster()).unwrap(); assert!(l.raster.as_ref().is_some_and(|r| r.data.is_some()), "read again from the file"); let _ = std::fs::remove_dir_all(dir); } /// Two CityJSON types that read the same once cut to a layer name are listed under distinct names, /// and picking either adds only its own layer (#1375). #[test] fn cityjson_types_cut_to_one_name_are_picked_apart() { // 64: the characters of a type a layer name keeps (`citymodel::MAX_TYPE_IN_NAME`). let long = "+".to_string() + &"A".repeat(64); let object = |ty: String| json!({"type": ty, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}]}); let model = json!({ "type": "CityJSON", "version": "2.0", "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, "metadata": {"referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/4326"}, "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0]], "CityObjects": {"a": object(format!("{long}x")), "b": object(format!("{long}y"))}, }) .to_string(); let cut: String = long.chars().take(64).collect(); let dir = std::env::temp_dir().join(format!("rustgis-cityjson-names-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("m.city.json"); std::fs::write(&path, &model).unwrap(); let mut s = Session::new(Project::default()); let listed = s.run("info.fileLayers", &json!({"path": path.display().to_string()})).unwrap(); let (first, second) = (format!("m {cut}"), format!("m {cut} (2)")); assert_eq!(listed["layers"], json!([first, second])); for (want, ty) in [(&first, format!("{long}x")), (&second, format!("{long}y"))] { let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "m.city.json", "text": model, "layer": want})).unwrap(); let added: Vec<_> = s.map().layers.iter().map(|l| l.name.clone()).collect(); assert_eq!(added, [want.as_str()]); let layer = s.map().layer_by_name(want).unwrap(); let at = layer.dataset.field_index("type").unwrap(); assert_eq!(layer.dataset.features[0].attrs[at], rustgis_data::Value::Text(ty)); } let _ = std::fs::remove_dir_all(&dir); } /// A CityJSON model through Add Data: a `.city.json` adds every layer, with the coordinate system /// given when it names none; a `.json` adds its first and says what else it holds. Asked to stream, /// neither is taken for GeoJSON. #[test] fn cityjson_layers_and_the_coordinate_system_given() { let dir = std::env::temp_dir().join(format!("rustgis-cityjson-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let object = |ty: &str| json!({"type": ty, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}]}); let mut model = json!({ "type": "CityJSON", "version": "2.0", "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, "vertices": [[85000, 447000, 0], [85100, 447000, 0], [85100, 447100, 0]], "CityObjects": {"a": object("Bridge"), "b": object("Road")}, }); let city = dir.join("model.city.json"); std::fs::write(&city, model.to_string()).unwrap(); let path = city.display().to_string(); let mut s = Session::new(Project::default()); let e = s.run("layer.addData", &json!({"path": path})).unwrap_err(); assert!(e.contains("names no coordinate system") && e.contains("crs"), "{e}"); assert!(s.map().layer_by_name("model Bridge").is_none(), "not placed by a guess"); for stream in [false, true] { let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path, "crs": "EPSG:28992", "stream": stream})).unwrap(); assert!(s.map().layer_by_name("model Bridge").is_some() && s.map().layer_by_name("model Road").is_some()); } model["metadata"] = json!({"referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/7415"}); let plain = dir.join("plain.json"); std::fs::write(&plain, model.to_string()).unwrap(); for stream in [false, true] { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": plain.display().to_string(), "stream": stream})).unwrap(); assert!(s.map().layer_by_name("plain Bridge").is_some() && s.map().layer_by_name("plain Road").is_none()); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("it also holds plain Road")), "{:?}", s.notices_since(mark)); } // In the coordinate system given, a `.json` adds all its layers too, over the one it names. let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": plain.display().to_string(), "crs": "EPSG:28992"})).unwrap(); assert!(s.map().layer_by_name("plain Bridge").is_some() && s.map().layer_by_name("plain Road").is_some()); // As text, the same: one picked says what the file as a whole says, though the first layer has // gone. A GeoJSON named .json with a coordinate system given is still read as GeoJSON. let mut s = Session::new(Project::default()); let mark = s.notice_mark(); let params = json!({"fileName": "text.city.json", "text": model.to_string(), "crs": "EPSG:28992", "layer": "Road"}); s.run("layer.addDataText", ¶ms).unwrap(); assert!(s.map().layer_by_name("text Road").is_some() && s.map().layer_by_name("text Bridge").is_none()); model["CityObjects"]["c"] = json!({"type": "Bridge", "geometry": [{"type": "Blob", "boundaries": []}]}); let params = json!({"fileName": "said.city.json", "text": model.to_string(), "crs": "EPSG:28992", "layer": "Road"}); s.run("layer.addDataText", ¶ms).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("unknown types")), "{:?}", s.notices_since(mark)); // The first layer's own notice stays with it: a line whose heights were dropped isn't the Road's. model["CityObjects"]["c"] = json!({"type": "Alley", "geometry": [{"type": "MultiLineString", "boundaries": [[0, 1]]}]}); model["vertices"] = json!([[85000, 447000, 0], [85100, 447000, 9], [85100, 447100, 0]]); let mark = s.notice_mark(); let params = json!({"fileName": "own.city.json", "text": model.to_string(), "crs": "EPSG:28992", "layer": "Road"}); s.run("layer.addDataText", ¶ms).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("Z or M")), "{:?}", s.notices_since(mark)); // Its layers can be listed in the coordinate system given. let mut s = Session::new(Project::default()); assert!(s.run("info.fileLayers", &json!({"path": path})).unwrap_err().contains("names no coordinate system")); let listed = s.run("info.fileLayers", &json!({"path": path, "crs": "EPSG:28992"})).unwrap(); assert_eq!(listed["layers"], json!(["model Bridge", "model Road"])); // Named .json, as Add Data adds it; a GeoJSON beside it is still one layer. let listed = s.run("info.fileLayers", &json!({"path": plain.display().to_string(), "crs": "EPSG:28992"})).unwrap(); assert_eq!(listed["layers"], json!(["plain Bridge", "plain Road"])); let geojson = dir.join("points.json"); std::fs::write(&geojson, r#"{"type":"FeatureCollection","features":[]}"#).unwrap(); let listed = s.run("info.fileLayers", &json!({"path": geojson.display().to_string(), "crs": "EPSG:28992"})).unwrap(); assert_eq!(listed["count"], 1); // Uploaded as .json, with a layer picked: only that layer. use base64::Engine; let mut s = Session::new(Project::default()); let data = base64::engine::general_purpose::STANDARD.encode(std::fs::read(&plain).unwrap()); s.run("layer.addDataBytes", &json!({"fileName": "up.json", "data": data, "crs": "EPSG:28992", "layer": "Road"})).unwrap(); assert!(s.map().layer_by_name("up Road").is_some() && s.map().layer_by_name("up Bridge").is_none()); // A layer picked from a `.json` that names its system, with no `crs`: that layer, by every route. let plain_text = std::fs::read_to_string(&plain).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": plain.display().to_string(), "layer": "Road"})).unwrap(); assert!(s.map().layer_by_name("plain Road").is_some() && s.map().layer_by_name("plain Bridge").is_none()); s.run("layer.addDataText", &json!({"fileName": "t.json", "text": plain_text, "layer": "Road"})).unwrap(); assert!(s.map().layer_by_name("t Road").is_some() && s.map().layer_by_name("t Bridge").is_none()); s.run("layer.addDataBytes", &json!({"fileName": "b.json", "data": data, "layer": "Road"})).unwrap(); assert!(s.map().layer_by_name("b Road").is_some() && s.map().layer_by_name("b Bridge").is_none()); assert!(s.run("layer.addData", &json!({"path": plain.display().to_string(), "layer": "Canal"})).unwrap_err().contains("no layer")); // Listed as they are added, with no crs: the names a layer is picked by. let listed = s.run("info.fileLayers", &json!({"path": plain.display().to_string()})).unwrap(); assert_eq!(listed["layers"], json!(["plain Bridge", "plain Road"])); // Zipped: a layer picked says what the model says, and the layers are listed as they're added. let mut zipped: Value = serde_json::from_str(&plain_text).unwrap(); zipped["CityObjects"]["c"] = json!({"type": "Bridge", "geometry": [{"type": "Blob", "boundaries": []}]}); let archive = dir.join("models.zip"); let write_zip = |model: &Value| { let zip = rustgis_data::zip::write(&[("d/m.city.json".to_string(), model.to_string().into_bytes())]).unwrap(); std::fs::write(&archive, zip).unwrap(); }; write_zip(&zipped); let zip_path = archive.display().to_string(); let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": zip_path, "layer": "Road"})).unwrap(); assert!(s.map().layer_by_name("m Road").is_some() && s.map().layer_by_name("m Bridge").is_none()); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("unknown types")), "{:?}", s.notices_since(mark)); zipped["CityObjects"].as_object_mut().unwrap().remove("c"); write_zip(&zipped); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": zip_path, "layer": "Road", "name": "again"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("unknown types")), "{:?}", s.notices_since(mark)); zipped.as_object_mut().unwrap().remove("metadata"); write_zip(&zipped); assert!(s.run("info.fileLayers", &json!({"path": zip_path})).is_err(), "no system to place it by"); let listed = s.run("info.fileLayers", &json!({"path": zip_path, "crs": "EPSG:28992"})).unwrap(); assert_eq!(listed["layers"], json!(["m Bridge", "m Road"])); let _ = std::fs::remove_dir_all(dir); } /// A CityGML model through Add Data, as a CityJSON one: every layer, in the coordinate system given /// when it names none, by path, text and zip; a layer picked keeps what the file as a whole says. #[test] fn citygml_layers_and_the_coordinate_system_given() { let dir = std::env::temp_dir().join(format!("rustgis-citygml-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let object = |ty: &str, x: u32| { format!( "\ {x} 447000 0 {} 447000 0 {} 447100 0 {x} 447000 0\ ", x + 100, x + 100 ) }; let model = format!( r#"{}{}"#, object("Building", 85000), object("BuildingPart", 86000) ); let gml = dir.join("model.gml"); std::fs::write(&gml, &model).unwrap(); let path = gml.display().to_string(); let both = |s: &Session, stem: &str| { (s.map().layer_by_name(&format!("{stem} Building")).is_some(), s.map().layer_by_name(&format!("{stem} BuildingPart")).is_some()) }; // Placed in the Netherlands, as EPSG:28992 puts (85000, 447000). let placed = |s: &Session, name: &str| { let e = s.map().layer_by_name(name).map(|l| l.dataset.extent()).unwrap(); assert!((4.0..5.0).contains(&e.min_x) && (51.5..52.5).contains(&e.min_y), "{name}: {e:?}"); }; let mut s = Session::new(Project::default()); let e = s.run("layer.addData", &json!({"path": path})).unwrap_err(); assert!(e.contains("names no coordinate system") && e.contains("crs"), "{e}"); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path, "crs": "EPSG:28992"})).unwrap(); assert_eq!(both(&s, "model"), (true, true)); placed(&s, "model Building"); // A layer picked, by path and as text: that layer, with the file's own notices. for pick in ["Building", "BuildingPart"] { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": path, "crs": "EPSG:28992", "layer": pick})).unwrap(); let text = json!({"fileName": "text.gml", "text": model, "crs": "EPSG:28992", "layer": pick}); s.run("layer.addDataText", &text).unwrap(); assert!(s.map().layer_by_name(&format!("model {pick}")).is_some() && s.map().layer_by_name(&format!("text {pick}")).is_some()); placed(&s, &format!("model {pick}")); placed(&s, &format!("text {pick}")); assert_eq!(s.map().layers.len(), 2, "only the layer picked"); let said = s.notices_since(mark).iter().filter(|n| n.text.contains("appearances")).count(); assert_eq!(said, 2, "{:?}", s.notices_since(mark)); } // As text with no layer picked, every layer; named .xml, the same. let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "t.xml", "text": model, "crs": "EPSG:28992"})).unwrap(); assert_eq!(both(&s, "t"), (true, true)); placed(&s, "t Building"); // Listed in the coordinate system given, and zipped. let mut s = Session::new(Project::default()); assert!(s.run("info.fileLayers", &json!({"path": path})).is_err(), "no system to place it by"); let listed = s.run("info.fileLayers", &json!({"path": path, "crs": "EPSG:28992"})).unwrap(); let mut layers: Vec = serde_json::from_value(listed["layers"].clone()).unwrap(); layers.sort(); assert_eq!(layers, ["model Building", "model BuildingPart"]); let archive = dir.join("models.zip"); std::fs::write(&archive, rustgis_data::zip::write(&[("d/m.gml".to_string(), model.clone().into_bytes())]).unwrap()).unwrap(); s.run("layer.addData", &json!({"path": archive.display().to_string(), "crs": "EPSG:28992"})).unwrap(); assert_eq!(both(&s, "m"), (true, true)); placed(&s, "m Building"); // A model naming its system: as text with no options, every layer; zipped with another system // given, the one it names. let named = model.replace("", r#""#); let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "n.gml", "text": named})).unwrap(); assert_eq!(both(&s, "n"), (true, true)); placed(&s, "n Building"); let archive = dir.join("named.zip"); std::fs::write(&archive, rustgis_data::zip::write(&[("d/z.gml".to_string(), named.clone().into_bytes())]).unwrap()).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": archive.display().to_string(), "crs": "EPSG:3857"})).unwrap(); assert_eq!(both(&s, "z"), (true, true)); placed(&s, "z Building"); let _ = std::fs::remove_dir_all(dir); } /// A city model through Add Data on the desktop is read on a thread of its own, and the command /// takes that read: the same layers and notices as when it reads as it runs, with a layer picked /// and a coordinate system given too. A CityJSON `.json` with options given reads as it runs. #[cfg(not(target_arch = "wasm32"))] #[test] fn city_models_are_read_ahead_as_they_read_inline() { if isolate_process_test("tests::city_models_are_read_ahead_as_they_read_inline") { return; } use rustgis_analysis::control::Control; let dir = std::env::temp_dir().join(format!("rustgis-city-ahead-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let object = |ty: &str, x: u32| { format!( "\ {x} 447000 0 {} 447000 0 {} 447100 0 {x} 447000 0\ ", x + 100, x + 100 ) }; let gml = format!( r#"{}{}"#, object("Building", 85000), object("BuildingPart", 86000) ); let surface = |ty: &str| json!({"type": ty, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}]}); let cityjson = json!({ "type": "CityJSON", "version": "2.0", "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, "vertices": [[85000, 447000, 0], [85100, 447000, 0], [85100, 447100, 0]], "CityObjects": {"a": surface("Bridge"), "b": surface("Road")}, }) .to_string(); let files = [("model.gml", gml.clone()), ("model.xml", gml), ("model.city.json", cityjson.clone())]; // What a session holds after Add Data: its layers' names and features, and what it said. let outcome = |s: &Session, mark: u64| { let layers: Vec<(String, usize)> = s.map().layers.iter().map(|l| (l.name.clone(), l.dataset.features.len())).collect(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); (layers, said) }; for (name, text) in files { let path = dir.join(name); for options in [ json!({"crs": "EPSG:28992"}), json!({"crs": "EPSG:28992", "layer": "Building"}), json!({"crs": "EPSG:28992", "layer": "Bridge"}), json!({"crs": "EPSG:bogus"}), json!({}), ] { std::fs::write(&path, &text).unwrap(); let mut p = options.clone(); p["path"] = json!(path.display().to_string()); let mut inline = Session::new(Project::default()); let mark = inline.notice_mark(); let expected = inline.run("layer.addData", &p).map(|_| outcome(&inline, mark)); let mut s = Session::new(Project::default()); let mark = s.notice_mark(); let ahead = crate::cmd::reads::read_ahead(&mut s, "layer.addData", &p).unwrap(); assert!(ahead.reads(), "{name} {options}: read ahead"); let read = ahead.run(&Control::new()).unwrap(); // The file gone: what the command adds can only come from the read. std::fs::remove_file(&path).unwrap(); let got = crate::cmd::reads::read_land(&mut s, read).map(|_| outcome(&s, mark)); assert!(s.read_ahead.is_none(), "{name} {options}: the read was taken"); assert_eq!(got, expected, "{name} {options}"); } } // A CityJSON model named .json with a coordinate system given is read as the command runs. let json_path = dir.join("model.json"); std::fs::write(&json_path, &cityjson).unwrap(); let mut s = Session::new(Project::default()); let p = json!({"path": json_path.display().to_string(), "crs": "EPSG:28992"}); assert!(!crate::cmd::reads::read_ahead(&mut s, "layer.addData", &p).unwrap().reads()); // Other GML and XML are read as the command runs. let other = dir.join("other.gml"); std::fs::write(&other, r#""#).unwrap(); assert!(!crate::cmd::reads::read_ahead(&mut s, "layer.addData", &json!({"path": other.display().to_string()})).unwrap().reads()); let _ = std::fs::remove_dir_all(dir); } /// GML, MapInfo MIF/MID and TopoJSON through the commands, by path, text and zip. #[test] fn gml_mapinfo_and_topojson() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-native-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); let gml = r#" 10,50A "#; s.run("layer.addDataText", &json!({"fileName": "towns.gml", "text": gml})).unwrap(); assert_eq!(s.map().layer_by_name("towns").map(|l| l.dataset.features.len()), Some(1)); // A MIF beside its MID, by path. let mif = "Version 300\nDelimiter \",\"\nCoordSys Earth Projection 1, 104\nColumns 1\n name Char(10)\nData\nPoint 1 2\nPoint 3 4\n"; std::fs::write(dir.join("sites.mif"), mif).unwrap(); std::fs::write(dir.join("sites.mid"), "\"X\"\n\"Y\"\n").unwrap(); s.run("layer.addData", &json!({"path": dir.join("sites.mif").display().to_string()})).unwrap(); let l = s.map().layer_by_name("sites").unwrap(); assert_eq!(l.dataset.features[1].attrs[0], rustgis_data::Value::Text("Y".into())); // Zipped together, uploaded. let zip = rustgis_data::zip::write(&[("a/sites2.mif".into(), mif.as_bytes().to_vec()), ("a/sites2.mid".into(), b"\"P\"\n\"Q\"\n".to_vec())]) .unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(zip); s.run("layer.addDataBytes", &json!({"fileName": "mif.zip", "data": b64})).unwrap(); assert_eq!(s.map().layer_by_name("sites2").unwrap().dataset.features[0].attrs[0], rustgis_data::Value::Text("P".into())); // TopoJSON, as .topojson and as .json. let topo = r#"{"type":"Topology","objects":{"pts":{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[5,6],"properties":{"n":1}}]}},"arcs":[]}"#; s.run("layer.addDataText", &json!({"fileName": "a.topojson", "text": topo})).unwrap(); s.run("layer.addDataText", &json!({"fileName": "b.json", "text": topo})).unwrap(); assert!(s.map().layer_by_name("a").is_some() && s.map().layer_by_name("b").is_some()); let _ = std::fs::remove_dir_all(dir); } /// Fields of the World's fields and density are two looks of one archive and stay side by side; /// the fields again at another score threshold replace the earlier ones. #[test] fn fields_of_the_world_looks() { let mut s = Session::new(Project::default()); s.run("fieldsOfTheWorld.add", &json!({"year": 2024, "draw": "fields"})).unwrap(); s.run("fieldsOfTheWorld.add", &json!({"year": 2024, "draw": "density"})).unwrap(); s.run("fieldsOfTheWorld.add", &json!({"year": 2024, "draw": "fields", "minScore": 50})).unwrap(); let names: Vec<&str> = s.map().tiles.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, ["Fields of the World 2024 fields", "Fields of the World 2024 density"]); assert!(s.map().tiles[0].style_layers.contains("50")); // Both replaces the parts (not another overlay of the archive); a part then replaces both. let url = crate::cmd::webdata::fields_url(2024); s.run("map.addTiles", &json!({"url": url, "kind": "pmtiles", "name": "My fields", "overlay": true})).unwrap(); s.run("fieldsOfTheWorld.add", &json!({"year": 2024})).unwrap(); assert_eq!(s.map().tiles.iter().map(|t| t.name.as_str()).collect::>(), ["Fields of the World 2024", "My fields"]); s.run("map.removeTiles", &json!({"name": "My fields"})).unwrap(); s.run("fieldsOfTheWorld.add", &json!({"year": 2024, "draw": "density"})).unwrap(); assert_eq!(s.map().tiles.iter().map(|t| t.name.as_str()).collect::>(), ["Fields of the World 2024 density"]); } /// A time-enabled WMTS layer (NASA GIBS's) is added at the date asked for, or its default; an /// ArcGIS services directory lists its folders and services to open, which aren't added. #[test] fn service_times_and_directories() { const WMTS: &str = r#" True ColorTC Time2024-06-022000-02-24/2024-06-02/P1D gm gmEPSG:3857 0559082264.0287178-20037508.34278925 20037508.34278925256256 1279541132.0143589-20037508.34278925 20037508.34278925256256 "#; let mut s = Session::new(Project::default()); let url = "https://gibs.example.org/wmts/1.0.0/WMTSCapabilities.xml"; let v = s.run("service.layers", &json!({"url": url, "text": WMTS})).unwrap(); assert_eq!(v["layers"][0]["time"]["default"], "2024-06-02"); s.run("service.add", &json!({"url": url, "layer": "TC", "text": WMTS})).unwrap(); s.run("service.add", &json!({"url": url, "layer": "TC", "text": WMTS, "time": "2020-01-05"})).unwrap(); let urls: Vec<(&str, &str)> = s.map().tiles.iter().map(|t| (t.name.as_str(), t.url.as_str())).collect(); assert_eq!( urls, [ ("True Color 2020-01-05", "https://gibs.example.org/TC/default/2020-01-05/gm/{z}/{y}/{x}.jpeg"), ("True Color", "https://gibs.example.org/TC/default/2024-06-02/gm/{z}/{y}/{x}.jpeg") ] ); assert!(s.run("service.add", &json!({"url": url, "layer": "TC", "text": WMTS, "time": "../../etc"})).is_err()); // Its matrices are zooms 0 and 1: past them its tiles are stretched, not asked for. assert!(s.map().tiles.iter().all(|t| (t.min_zoom, t.max_zoom) == (0, 1))); assert_eq!((v["layers"][0]["minZoom"].as_u64(), v["layers"][0]["maxZoom"].as_u64()), (Some(0), Some(1))); // A directory: its folders and services, each opened by its own address. let root = "https://x.example.org/arcgis/rest/services"; let dir = r#"{"currentVersion": 11, "folders": ["Hydro"], "services": [{"name": "Topo", "type": "MapServer"}]}"#; let v = s.run("service.layers", &json!({"url": root, "text": dir})).unwrap(); assert_eq!(v["kind"], "ArcGIS Services Directory"); assert_eq!(v["layers"][1]["offer"], json!({"type": "service", "url": "https://x.example.org/arcgis/rest/services/Topo/MapServer"})); let e = s.run("service.add", &json!({"url": root, "text": dir, "layer": "Topo/MapServer"})).unwrap_err(); assert!(e.contains("service.layers"), "{e}"); let foreign = WMTS.replace("https://gibs.example.org/TC/default/", "https://fallback-b.invalid/TC/default/"); service_logins::foreign_service_fallback_stays_anonymous("wmts", url, "TC", WMTS, &foreign); } /// Adding an OGC API collection the listing left out, as 3D Tiles (#1267), says why, not that the /// document isn't GeoJSON. #[test] fn adding_a_collection_of_3d_tiles_says_why_it_is_left_out() { let mut s = Session::new(Project::default()); let url = "https://x.example.org/ogc/v1/collections"; let doc = r#"{"collections": [{"id": "gebouwen", "title": "3D Tiles Gebouwen", "itemType": "feature", "links": [{"rel": "items", "type": "application/json+3dtiles", "href": "https://x.example.org/ogc/v1/collections/gebouwen/3dtiles?f=json"}]}]}"#; let e = s.run("service.add", &json!({"url": url, "text": doc, "layer": "Gebouwen"})).unwrap_err(); assert!(e.contains("no layer 'Gebouwen': collection 'gebouwen' offers 3D Tiles, which RustGIS doesn't draw"), "{e}"); let e = s.run("service.add", &json!({"url": url, "text": doc, "layer": "gebouwen_x"})).unwrap_err(); assert!(e.ends_with("(service.layers lists them)"), "{e}"); } /// A secured ArcGIS service given by an address with `?token=…` (#914): the token is kept for its /// server and sent, as the request's credential, to the addresses built from the service's document (its tiles, /// its layers' queries), and no address the session lists or keeps carries it. #[test] fn an_arcgis_token_in_a_service_address_reaches_its_layers() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::an_arcgis_token_in_a_service_address_reaches_its_layers") { return; } use crate::render::rustgis_tiles::arcgis_auth; let mut s = Session::new(Project::default()); let base = "https://secure914.invalid/server/rest/services/Roads/MapServer"; let doc = r#"{"currentVersion": 11, "mapName": "Roads", "capabilities": "Map,Query,Data", "singleFusedMapCache": true, "tileInfo": {"rows": 256, "spatialReference": {"wkid": 102100}, "lods": [{"level": 0, "resolution": 156543.033928041}]}, "layers": [{"id": 0, "name": "Highways"}]}"#; let v = s.run("service.layers", &json!({"url": format!("{base}?f=json&%2574oken=RGSYNTH_3"), "text": doc})).unwrap(); assert!(!v.to_string().contains("RGSYNTH_3"), "{v}"); let tiles = format!("{base}/tile/0/0/0"); let query = format!("{base}/0/query?where=1%3D1&f=geojson"); assert_eq!(arcgis_auth::authorization(&tiles).as_deref(), Some("Bearer RGSYNTH_3")); assert_eq!(arcgis_auth::authorization(&query).as_deref(), Some("Bearer RGSYNTH_3")); // Another server never gets it. assert_eq!(arcgis_auth::authorization("https://other914.invalid/server/rest/services/Roads/MapServer/tile/0/0/0"), None); // Added, the tile layer keeps the address without the token. let layer = v["layers"].as_array().unwrap().iter().find(|l| l["offer"]["type"] == "tiles").unwrap()["name"].as_str().unwrap().to_string(); s.run("service.add", &json!({"url": format!("{base}?token=RGSYNTH_3"), "layer": layer, "text": doc})).unwrap(); let project = format!("{:?}", s.project); assert!(project.contains("secure914.invalid") && !project.contains("RGSYNTH_3")); // Listed and forgotten by arcgis.status and arcgis.signOut. let status = s.run("arcgis.status", &json!({})).unwrap(); assert!( status["sessions"].as_array().unwrap().iter().any(|x| x["portal"] == "https://secure914.invalid/server" && x["kind"] == "token") ); assert!(!status.to_string().contains("RGSYNTH_3")); s.run("arcgis.signOut", &json!({"portal": "https://secure914.invalid/server/"})).unwrap(); assert_eq!(arcgis_auth::authorization(&tiles), None); // Junk is refused rather than taken for "every session". for junk in [json!({"portal": 7}), json!({"portal": [true]}), json!({"portal": {"a": 1}})] { assert!(s.run("arcgis.signOut", &junk).is_err()); } // Another kind of service keeps its own token parameter. let wms = "https://wms914.example.org/rest/services/wms?token=keep-it"; let kept = crate::cmd::service::without_token(&json!({"url": wms, "type": "wms"})).unwrap(); assert!(kept.is_none() && arcgis_auth::authorization(wms).is_none()); } /// A layer its service draws on request (#1162): one picture of the view when added from its /// service, tiles of 256, 512 or 1024 pixels when asked; a project saved before keeps its tiles; /// tiles cut from a tile set refuse both. #[test] fn wms_layers_draw_as_one_picture_or_tiles_of_a_size() { const WMS: &str = r#" Test WMS image/png EPSG:3857EPSG:4326roadsRoads"#; let mut s = Session::new(Project::default()); s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "roads", "text": WMS})).unwrap(); let roads = |s: &Session| s.map().tiles.iter().find(|t| t.name == "Roads").cloned().unwrap(); assert!(roads(&s).single_image, "one picture of the view, from its service"); // Offered in another system too: drawn in Web Mercator as it comes, its other systems kept // for maps in them (as most services are). assert!(roads(&s).url.contains("#rustgis-warp=") && roads(&s).sized_tiles(), "{}", roads(&s).url); let listed = s.run("map.tiles", &json!({})).unwrap(); assert_eq!((listed[0]["singleImage"].clone(), listed[0]["tileSize"].clone()), (json!(true), json!(256))); // Tiles of 1024 pixels: asked for that size. s.run("map.setTiles", &json!({"name": "Roads", "singleImage": false, "tileSize": 1024.0})).unwrap(); let t = roads(&s); assert!(!t.single_image && t.tile_size == 1024); assert!(t.url.contains("&WIDTH=1024&HEIGHT=1024&") && t.url.contains("#rustgis-warp="), "{}", t.url); // Its pictures in another system take any size still. let base = t.url.split('#').next().unwrap(); assert!(rustgis_data::ogc::warp::Warp::map_template(base).contains("BBOX={nbbox}&WIDTH={nw}&HEIGHT={nh}")); // Kept with the project, and undone. let saved: rustgis_map::TileLayer = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap(); assert_eq!((saved.single_image, saved.tile_size), (false, 1024)); assert!(s.undo()); let t = roads(&s); assert!(t.single_image && t.tile_size == 256 && t.url.contains("&WIDTH=256&HEIGHT=256&")); // Junk refused, nothing changed. for bad in [json!({"name": "Roads", "tileSize": 300}), json!({"name": "Roads", "tileSize": "big"}), json!({"name": "Roads", "tileSize": 2048})] { assert!(s.run("map.setTiles", &bad).is_err(), "{bad}"); } assert_eq!(roads(&s).tile_size, 256); // A project saved before: tiles, as then. let mut old = serde_json::to_value(roads(&s)).unwrap(); old.as_object_mut().unwrap().remove("singleImage"); let old: rustgis_map::TileLayer = serde_json::from_value(old).unwrap(); assert!(!old.single_image); // Tiles cut from a tile set are neither. s.run("map.addTiles", &json!({"url": "https://tile.example.org/{z}/{x}/{y}.png", "name": "XYZ", "overlay": true})).unwrap(); let e = s.run("map.setTiles", &json!({"name": "XYZ", "singleImage": true})).unwrap_err(); assert!(e.contains("draws on request"), "{e}"); assert!(s.run("map.setTiles", &json!({"name": "XYZ", "tileSize": 1024})).is_err()); assert!(s.run("map.addTiles", &json!({"url": "https://tile.example.org/{z}/{x}/{y}.png", "singleImage": true})).is_err()); // Nor is an address by its box with no size to write (a generic template): asked for at its // service's default size, it stays in its own tiles. let bare = "https://maps.example.org/render?bbox={bbox-epsg-3857}&format=png"; assert!(!rustgis_map::tiles::drawn_on_request(bare)); assert!(s.run("map.addTiles", &json!({"url": bare, "name": "Bare", "overlay": true, "singleImage": true})).is_err()); s.run("map.addTiles", &json!({"url": bare, "name": "Bare", "overlay": true})).unwrap(); assert!(s.run("map.setTiles", &json!({"name": "Bare", "tileSize": 1024})).is_err()); assert!(rustgis_map::tiles::drawn_on_request("https://maps.example.org/wms?BBOX={nbbox}&width={nw}&HEIGHT={nh}&f=png")); assert!(rustgis_map::tiles::drawn_on_request("https://maps.example.org/wms?BBOX={bbox-epsg-3857}&WIDTH={width}&HEIGHT={height}")); assert!(!rustgis_map::tiles::drawn_on_request("https://maps.example.org/wms?BBOX={bbox-epsg-3857}&WIDTH={w}&HEIGHT=256")); // Added by its address, a layer drawn on request is one picture too, unless tiles are asked // for; a bare template, and tiles cut from a tile set, are tiles. let wms = "https://m.example.org/wms?SERVICE=WMS&REQUEST=GetMap&LAYERS=a&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image%2Fpng"; let single = |s: &Session, name: &str| s.map().tiles.iter().find(|t| t.name == name).unwrap().single_image; s.run("map.addTiles", &json!({"url": wms, "name": "ByAddress", "overlay": true})).unwrap(); assert!(single(&s, "ByAddress")); s.run("map.addTiles", &json!({"url": wms, "name": "AsTiles", "overlay": true, "singleImage": false})).unwrap(); assert!(!single(&s, "AsTiles")); s.run("map.addTiles", &json!({"url": wms.replace("LAYERS=a", "LAYERS=b"), "name": "Sized", "overlay": true, "tileSize": 512})).unwrap(); assert!(!single(&s, "Sized")); assert!(!single(&s, "Bare") && !single(&s, "XYZ")); // A map service's export added with a tile size: its size asked for. let export = "https://s.example.org/arcgis/rest/services/x/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png32&f=image"; s.run("map.addTiles", &json!({"url": export, "name": "Export", "overlay": true, "tileSize": 512})).unwrap(); let t = s.map().tiles.iter().find(|t| t.name == "Export").unwrap(); assert!(t.url.contains("&size=512,512&") && t.tile_size == 512 && !t.single_image, "{}", t.url); } /// #1592: a legend picture the layer cannot keep (an address too long, as a same-origin link can /// become once its spaces are encoded for the login) is counted in a notice, not dropped silently. #[test] fn service_legends_left_out_are_reported() { const WMS: &str = r#" Test WMS image/png EPSG:3857roadsRoads "#; let long = format!("https://maps.example.com/legends/{}.png", "a".repeat(rustgis_map::tiles::MAX_LEGEND_URL)); for (legend, left_out) in [(long.as_str(), true), ("https://maps.example.com/legends/roads.png", false)] { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "roads", "text": WMS.replace("LEGEND", legend)})) .unwrap(); let notices = s.notices_since(mark); let reported = notices.iter().any(|n| n.text.contains("1 legend picture(s) the service listed were left out")); assert_eq!(reported, left_out, "{notices:?}"); assert_eq!(s.map().tiles.first().map(|t| t.legends.len()), Some(usize::from(!left_out))); } } /// A WMS layer added from its service takes its legends (the service's GetLegendGraphic, then /// its style's picture); any tile layer can be given one, or several for their scales; they and /// whether they show are kept with the project and undo; junk is refused. #[test] fn tile_layers_take_their_services_legends() { const WMS: &str = r#" Test WMS image/png image/png EPSG:3857roadsRoads bareBare"#; let mut s = Session::new(Project::default()); s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "roads", "text": WMS})).unwrap(); let legends = |s: &Session, name: &str| { let t = s.map().tiles.iter().find(|t| t.name == name).unwrap(); (t.legends.iter().map(|l| l.url.clone()).collect::>(), t.expanded) }; assert_eq!( legends(&s, "Roads"), ( vec![ "https://maps.example.com/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&LAYER=roads&FORMAT=image%2Fpng&SLD_VERSION=1.1.0" .to_string(), "https://maps.example.com/legends/roads.png".to_string() ], false ) ); // Listed, and shown when expanded: what the pane shows, not an edit (as a layer's classes). let listed = s.run("map.tiles", &json!({})).unwrap(); let roads = listed.as_array().unwrap().iter().find(|t| t["name"] == "Roads").unwrap().clone(); assert_eq!(roads["legends"][1]["url"], "https://maps.example.com/legends/roads.png"); assert_eq!(roads["expanded"], false); let steps = s.undo_depth(); let dirty = s.dirty(); s.run("map.expandTiles", &json!({"name": "Roads"})).unwrap(); assert!(legends(&s, "Roads").1); assert_eq!((s.undo_depth(), s.dirty()), (steps, dirty), "no undo step, nothing to save"); s.run("map.expandTiles", &json!({"name": "Roads", "on": true})).unwrap(); assert!(legends(&s, "Roads").1, "on keeps it shown"); s.run("map.expandTiles", &json!({"name": "Roads"})).unwrap(); assert!(!legends(&s, "Roads").1, "toggles"); s.run("map.expandTiles", &json!({"name": "Roads", "on": true})).unwrap(); assert!(s.run("map.expandTiles", &json!({"name": "Nope"})).is_err()); // A layer the service gives no legend has none, and none is listed. s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "bare", "text": WMS.replace("image/png", "")})).unwrap(); assert_eq!(legends(&s, "Bare").0, Vec::::new()); let listed = s.run("map.tiles", &json!({})).unwrap(); assert!(listed.as_array().unwrap().iter().find(|t| t["name"] == "Bare").unwrap().get("legends").is_none()); // Given by hand: an address, or a list with scales; set again, or taken away. s.run( "map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "name": "Topo", "legend": "https://tiles.example.com/legend.png"}), ) .unwrap(); assert_eq!(legends(&s, "Topo").0, ["https://tiles.example.com/legend.png"]); let scaled = json!(["https://t.example.com/near.png", {"url": "https://t.example.com/far.png", "minScale": "50000"}]); s.run("map.setTiles", &json!({"name": "Topo", "legend": scaled})).unwrap(); let t = s.map().tiles.iter().find(|t| t.name == "Topo").unwrap(); assert_eq!((t.legends.len(), t.legends[1].min_scale), (2, Some(50_000.0))); // Kept with the project. let text = serde_json::to_string(&s.project).unwrap(); let reopened = Session::new(serde_json::from_str(&text).unwrap()); assert_eq!(legends(&reopened, "Roads"), legends(&s, "Roads")); assert_eq!(legends(&reopened, "Topo"), legends(&s, "Topo")); s.run("map.setTiles", &json!({"name": "Topo", "legend": null})).unwrap(); assert_eq!(legends(&s, "Topo").0, Vec::::new()); for bad in [ json!("file:///etc/legend.png"), json!(7), json!([{"url": 3}]), json!([{"href": "https://x.org/l.png"}]), json!((0..20).map(|i| format!("https://x.org/{i}.png")).collect::>()), ] { assert!(s.run("map.setTiles", &json!({"name": "Topo", "legend": bad})).is_err(), "{bad}"); assert!(s.run("map.addTiles", &json!({"url": "https://other.example.com/{z}/{x}/{y}.png", "legend": bad})).is_err(), "{bad}"); } assert_eq!(legends(&s, "Topo").0, Vec::::new(), "a refusal changes nothing"); } /// A WMS layer that lost its times to the document's cap says so when it is added, and the time /// slider's refusal says why (#1519). #[test] fn a_wms_layer_past_the_time_cap_says_its_times_are_left_out() { let times: Vec = (0..1000).map(|i| (1000 + i).to_string()).collect(); let children: String = (0..60).map(|i| format!("c{i}C{i}")).collect(); let wms = format!( r#"RootEPSG:3857{}{children}"#, times.join(",") ); let mut s = Session::new(Project::default()); let url = "https://maps.example.com/wms"; let mark = s.notice_mark(); s.run("service.add", &json!({"url": url, "layer": "c0", "text": wms})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("times are left out")), "{:?}", s.notices_since(mark)); // 50,000 times go to the first 50 layers. s.run("service.add", &json!({"url": url, "layer": "c55", "text": wms})).unwrap(); let said = "C55: 1000 of its times are left out: RustGIS keeps at most 1000 for a layer and 50000 for one document's layers"; assert!(s.notices_since(mark).iter().any(|n| n.text == said), "{:?}", s.notices_since(mark)); let e = s.run("service.add", &json!({"url": url, "layer": "c55", "text": wms, "time": "slider"})).unwrap_err(); assert!(e.contains("its 1000 times are left out, past the 50000"), "{e}"); // A layer with 1,100 times of its own loses 100 to its own cap, not the document's: the notice says so. let own: String = (0..1100).map(|i| (1000 + i).to_string()).collect::>().join(","); let long = format!( r#"RootEPSG:3857longLong{own}"# ); let mut own_cap = Session::new(Project::default()); let mark = own_cap.notice_mark(); own_cap.run("service.add", &json!({"url": url, "layer": "long", "text": long})).unwrap(); let said = "Long: 100 of its times are left out: RustGIS keeps at most 1000 for a layer and 50000 for one document's layers"; assert!(own_cap.notices_since(mark).iter().any(|n| n.text == said), "{:?}", own_cap.notices_since(mark)); } /// A WMS layer with a time dimension (WMS 1.3.0 Annex C) is added at its default or the time /// asked for, named with it: one time where the layer takes single values, a list or interval /// where it takes several; GetFeatureInfo asks at the same time, its project's layer too. #[test] fn wms_layers_with_time() { const WMS: &str = r#" Test WMS image/png application/geo+json RootEPSG:3857 populationPopulation1990,2000,2025 decadesDecades1990/2020/P10Y roadsRoads nowNowcurrent onceOnce1946 "#; let mut s = Session::new(Project::default()); let url = "https://maps.example.com/wms"; let listed = s.run("service.layers", &json!({"url": url, "text": WMS})).unwrap(); let population = listed["layers"].as_array().unwrap().iter().find(|l| l["name"] == "population").unwrap(); assert_eq!(population["time"], json!({"default": "2025", "values": ["1990", "2000", "2025"], "count": 3, "multiple": false})); s.run("service.add", &json!({"url": url, "layer": "population", "text": WMS})).unwrap(); s.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": "1990"})).unwrap(); let tile = |s: &Session, name: &str| s.map().tiles.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name}")).url.clone(); assert!(tile(&s, "Population").ends_with("&TIME=2025"), "{}", tile(&s, "Population")); assert!(tile(&s, "Population 1990").ends_with("&TIME=1990"), "{}", tile(&s, "Population 1990")); // One time for a layer that takes single values; a list or interval for one that takes several. let e = s.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": "1990,2000"})).unwrap_err(); assert!(e.contains("a time is a date"), "{e}"); s.run("service.add", &json!({"url": url, "layer": "decades", "text": WMS, "time": "1990/2010"})).unwrap(); assert!(tile(&s, "Decades 1990/2010").ends_with("&TIME=1990%2F2010"), "{}", tile(&s, "Decades 1990/2010")); // GetFeatureInfo asks at the layer's time, a project's layer (without its request) too. s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let asked = |s: &mut Session| -> Vec { let v = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); v["featureInfo"].as_array().unwrap().iter().filter_map(|a| a["url"].as_str().map(str::to_string)).collect() }; let requests = asked(&mut s); for (name, time) in [("Population", "2025"), ("Population 1990", "1990"), ("Decades 1990/2010", "1990%2F2010")] { let layer = tile(&s, name); assert!( requests.iter().any(|r| r.contains("REQUEST=GetFeatureInfo") && r.ends_with(&format!("&TIME={time}")) && r.contains(if name.starts_with('D') { "LAYERS=decades" } else { "LAYERS=population" })), "{name} ({layer}): {requests:?}" ); } let text = serde_json::to_string(&s.project).unwrap(); let mut reopened = Session::new(serde_json::from_str(&text).unwrap()); assert!(reopened.map().tiles.iter().all(|t| t.feature_info.is_none())); let p = json!({"layer": "Population 1990", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true}); let r = reopened.run("identify.featureInfo", &p).unwrap(); let request = r["url"].as_str().unwrap(); assert!(request.contains("REQUEST=GetFeatureInfo") && request.ends_with("&TIME=1990"), "{request}"); // Following the time slider: it turns on with the layer's times, at its default, and each of // its steps draws (and asks) the layer at that time. let mut s = Session::new(Project::default()); let added = s.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": "slider"})).unwrap(); assert!(added["url"].as_str().is_some_and(|u| u.ends_with("&TIME=2025")), "{added}"); let mut keyed = Session::new(Project::default()); let capabilities = WMS.replace("https://maps.example.com/wms?", "https://maps.example.com/wms?token=RGSYNTH_service&"); let public = keyed.run("service.add", &json!({"url":url,"layer":"population","text":capabilities,"time":"slider"})).unwrap(); let live = &keyed.map().tiles[public["index"].as_u64().unwrap() as usize]; assert!(live.url.contains("RGSYNTH_service") && live.url.ends_with("TIME=2025")); assert!(!public["url"].as_str().unwrap().contains("RGSYNTH_service"), "moving to the default time cannot expose the live address"); let year = |y: i32| rustgis_data::time::parse_text(&format!("{y}-01-01")).unwrap(); let slider = s.map().time.clone().unwrap(); assert_eq!((slider.steps(), slider.current), (vec![year(1990), year(2000), year(2025)], year(2025))); let population = s.map().tiles.iter().find(|t| t.name == "Population").unwrap(); assert!(population.time_url.as_deref().is_some_and(|u| u.ends_with("&TIME={date:YYYY}")), "{:?}", population.time_url); assert!(population.url.ends_with("&TIME=2025"), "{}", population.url); s.run("time.goTo", &json!({"date": "1990"})).unwrap(); assert!(tile(&s, "Population").ends_with("&TIME=1990"), "{}", tile(&s, "Population")); s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let requests = asked(&mut s); assert!(requests.len() == 1 && requests[0].ends_with("&TIME=1990"), "{requests:?}"); // A second layer follows the slider that is on (`"time": true`, as for Zarr): its steps stay, // and it says the service may have no map at some of them. let mark = s.notice_mark(); s.run("service.add", &json!({"url": url, "layer": "decades", "text": WMS, "time": true})).unwrap(); assert_eq!(s.map().time.as_ref().unwrap().steps(), slider.steps()); assert!(s.notices_since(mark).is_empty(), "1990 is one of its times: {:?}", s.notices_since(mark)); assert!(tile(&s, "Decades").ends_with("&TIME=1990"), "{}", tile(&s, "Decades")); // Kept with the project, still following. let text = serde_json::to_string(&s.project).unwrap(); let mut reopened = Session::new(serde_json::from_str(&text).unwrap()); reopened.run("time.goTo", &json!({"date": "2000"})).unwrap(); assert!(tile(&reopened, "Population").ends_with("&TIME=2000"), "{}", tile(&reopened, "Population")); // One period: a regular slider, by its period, at the layer's default. let mut every = Session::new(Project::default()); every.run("service.add", &json!({"url": url, "layer": "decades", "text": WMS, "time": "slider"})).unwrap(); let t = every.map().time.clone().unwrap(); assert_eq!((t.start, t.end, t.unit.code(), t.interval, t.current), (year(1990), year(2020), "year", 10, year(2020))); assert_eq!(t.steps().len(), 4); assert!(tile(&every, "Decades").ends_with("&TIME=2020"), "{}", tile(&every, "Decades")); // A slider on at a time the layer doesn't list: the layer says so. let mut other = Session::new(Project::default()); other.run("time.set", &json!({"start": "2025", "end": "2026", "unit": "year"})).unwrap(); let mark = other.notice_mark(); other.run("service.add", &json!({"url": url, "layer": "decades", "text": WMS, "time": "slider"})).unwrap(); assert!( other.notices_since(mark).iter().any(|n| n.text.contains("Decades: follows the time slider's steps")), "{:?}", other.notices_since(mark) ); // Without a time asked for, a layer follows the slider; "default" (or false) adds it at its // service's default instead, and a layer whose only time is a name (`current`) is added at it. let mut plain = Session::new(Project::default()); plain.run("service.add", &json!({"url": url, "layer": "population", "text": WMS})).unwrap(); let t = plain.map().tiles.iter().find(|t| t.name == "Population").unwrap(); assert!(plain.map().time.is_some() && t.time_url.is_some() && t.url.ends_with("&TIME=2025"), "{}", t.url); for pin in [json!("default"), json!(false)] { let mut pinned = Session::new(Project::default()); pinned.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": pin})).unwrap(); let t = pinned.map().tiles.iter().find(|t| t.name == "Population").unwrap_or_else(|| panic!("{pin}")); assert!(pinned.map().time.is_none() && t.time_url.is_none() && t.url.ends_with("&TIME=2025"), "{pin}: {}", t.url); } let mut named = Session::new(Project::default()); named.run("service.add", &json!({"url": url, "layer": "now", "text": WMS})).unwrap(); assert!(named.map().time.is_none() && tile(&named, "Now").ends_with("&TIME=current"), "{}", tile(&named, "Now")); // Asked to follow the slider, such a layer can't: it says so. let e = named.run("service.add", &json!({"url": url, "layer": "now", "text": WMS, "time": "slider"})).unwrap_err(); assert!(e.contains("lists only one time, current"), "{e}"); // A layer with one time, a name or a date, is listed and added as a plain map, at that time. let v = named.run("service.layers", &json!({"url": url, "text": WMS})).unwrap(); let listed = |name: &str| v["layers"].as_array().unwrap().iter().find(|l| l["name"] == name).unwrap_or_else(|| panic!("{name}")).clone(); assert!(listed("now").get("time").is_none() && listed("once").get("time").is_none(), "{v}"); assert_eq!(listed("population")["time"]["default"], "2025"); named.run("service.add", &json!({"url": url, "layer": "once", "text": WMS})).unwrap(); assert!(named.map().time.is_none() && tile(&named, "Once").ends_with("&TIME=1946"), "{}", tile(&named, "Once")); // A year as a number is that time; an empty time, the service's default; anything else, refused. let mut given = Session::new(Project::default()); given.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": 1990})).unwrap(); assert!(tile(&given, "Population 1990").ends_with("&TIME=1990") && given.map().time.is_none()); given.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": " "})).unwrap(); assert!(tile(&given, "Population").ends_with("&TIME=2025") && given.map().time.is_none()); let e = given.run("service.add", &json!({"url": url, "layer": "population", "text": WMS, "time": ["1990"]})).unwrap_err(); assert!(e.contains("time is a date"), "{e}"); // A layer without times has none to follow it with. let e = s.run("service.add", &json!({"url": url, "layer": "roads", "text": WMS, "time": "slider"})).unwrap_err(); assert!(e.contains("no time to follow"), "{e}"); let foreign = WMS.replace("https://maps.example.com/wms?", "https://fallback-b.invalid/wms?"); service_logins::foreign_service_fallback_stays_anonymous("wms", url, "population", WMS, &foreign); } /// Identify asks a WMS layer what is at the point (GetFeatureInfo): the request from the /// capabilities, the answer read as results; a project's layer, which doesn't keep the request, /// reads its service's capabilities again; a layer that isn't queryable isn't asked. #[test] fn identify_asks_wms_layers() { const WMS: &str = r#" Test WMS image/png text/plainapplication/geo+json RootEPSG:3857 roadsRoads reliefRelief "#; const ANSWER: &str = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "id": "roads.3", "properties": {"name": "Main St"}, "geometry": null}]}"#; let mut s = Session::new(Project::default()); let url = "https://maps.example.com/wms"; s.run("service.add", &json!({"url": url, "layer": "relief", "text": WMS})).unwrap(); s.run("service.add", &json!({"url": url, "layer": "roads", "text": WMS})).unwrap(); s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let v = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); let asks = v["featureInfo"].as_array().unwrap(); assert_eq!(asks.len(), 1, "{v}"); assert_eq!(asks[0]["tileLayer"], "Roads"); // Topmost asks the highest layer that can be asked there: Relief isn't queryable. let top = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "topmost"})).unwrap(); assert_eq!(top["featureInfo"], v["featureInfo"], "{top}"); let request = asks[0]["url"].as_str().unwrap(); assert!( request.starts_with("https://maps.example.com/info?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&LAYERS=roads&"), "{request}" ); assert!(request.contains("&QUERY_LAYERS=roads&INFO_FORMAT=application%2Fgeo%2Bjson&") && request.contains("&I=50&J=50&"), "{request}"); let r = s.run("identify.featureInfo", &json!({"layer": "Roads", "text": ANSWER})).unwrap(); assert_eq!(r["results"][0]["layer"], "Roads › roads"); assert_eq!(r["results"][0]["tileLayer"], "Roads"); assert_eq!(r["results"][0]["attributes"]["name"], "Main St"); let e = s.run( "identify.featureInfo", &json!({"layer": "Roads", "text": "bad"}), ); assert!(e.unwrap_err().contains("bad")); assert!(s.run("identify.featureInfo", &json!({"layer": "nope", "text": ANSWER})).is_err()); // A hidden layer isn't asked; nothing else is there, so identify finds nothing. s.run("map.setTiles", &json!({"name": "Roads", "visible": false})).unwrap(); assert!(s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap().is_null()); s.run("map.setTiles", &json!({"name": "Roads", "visible": true})).unwrap(); // The project file is as before: it doesn't keep the request. let project = serde_json::to_string(&s.project).unwrap(); assert!(!project.contains("featureInfo") && !project.contains("GetFeatureInfo")); let mut reopened = Session::new(serde_json::from_str(&project).unwrap()); reopened.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); let asks = v["featureInfo"].as_array().unwrap(); assert_eq!(asks.len(), 2, "{v}"); assert!( asks.iter().all(|a| a["capabilities"] == "https://maps.example.com/wms?SERVICE=WMS&REQUEST=GetCapabilities&VERSION=1.3.0"), "{v}" ); // With its service's capabilities, each knows its request (or that it isn't asked). let r = reopened .run("identify.featureInfo", &json!({"layer": "Roads", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) .unwrap(); assert_eq!(r["url"].as_str(), Some(request)); // The other layer from the same service learned from those capabilities that it isn't asked, // without reading them again. let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); assert_eq!(v["featureInfo"].as_array().map(Vec::len), Some(1), "{v}"); assert_eq!(v["featureInfo"][0]["tileLayer"], "Roads", "{v}"); let r = reopened .run("identify.featureInfo", &json!({"layer": "Relief", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) .unwrap(); assert_eq!(r, json!({"results": []})); let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); assert_eq!(v["featureInfo"].as_array().map(Vec::len), Some(1), "{v}"); // A selection mode asks none. let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "selectable"})).unwrap(); assert!(v.is_null(), "{v}"); // Answers and capabilities past the download limit are refused, naming the layer. let huge = "x".repeat(2 * rustgis_data::ogc::MAX_INFO_BYTES + 1); for key in ["text", "capabilities"] { let e = reopened.run("identify.featureInfo", &json!({"layer": "Roads", key: huge})).unwrap_err(); assert!(e.starts_with("Roads: ") && e.contains("larger than"), "{e}"); } // A WMS layer added by its address (not from the service, not an overlay) is asked too. let mut added = Session::new(Project::default()); let template = "https://maps.example.com/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&LAYERS=roads&STYLES=&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image/png"; added.run("map.addTiles", &json!({"url": template, "name": "Mine"})).unwrap(); added.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let v = added.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); assert_eq!(v["featureInfo"][0]["tileLayer"], "Mine", "{v}"); // A service on the web whose GetFeatureInfo points into the local network isn't asked. let local = WMS.replace("https://maps.example.com/info?", "http://192.168.1.5/info?"); let mut s = Session::new(Project::default()); s.run("service.add", &json!({"url": url, "layer": "roads", "text": local})).unwrap(); s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let v = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); assert!(v.is_null(), "{v}"); let r = s.run("identify.featureInfo", &json!({"layer": "Roads", "lon": 8.5, "lat": 47.4, "request": true})).unwrap(); assert_eq!(r, json!({"results": []})); } /// A tile layer picked in Layers (`map.selectTiles`, view state like `layer.select`) is the one /// Selected in Layers identifies, whichever it is (a basemap too, which the other modes leave /// out); it stays picked when moved, and a map layer made active ends the pick for good. #[test] fn selected_in_layers_asks_the_tile_layer_picked() { use rustgis_data::{Dataset, GeometryType}; const WMS: &str = r#" Test WMS image/png application/geo+json RootEPSG:3857roadsRoads"#; let mut s = Session::new(Project::default()); s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "roads", "text": WMS})).unwrap(); // A basemap from the catalog, of the same service. let base = "https://maps.example.com/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&LAYERS=roads&STYLES=&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image/png"; s.run("map.addTiles", &json!({"url": base, "name": "Base", "id": "base-wms"})).unwrap(); let points = s.map_mut().add_dataset(Dataset::new("Points", GeometryType::Point, vec![])); s.run("layer.select", &json!({"layer": points})).unwrap(); s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); let identify = |s: &mut Session, mode: &str| s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": mode})).unwrap(); let asked = |v: &Value| -> Vec { v["featureInfo"].as_array().into_iter().flatten().map(|a| a["tileLayer"].as_str().unwrap_or_default().to_string()).collect() }; // The active layer has nothing there, and no tile layer is asked; the visible mode leaves the // basemap out. assert!(identify(&mut s, "selectedInContents").is_null()); assert_eq!(asked(&identify(&mut s, "visible")), ["Roads"]); let tiles = |s: &mut Session| s.run("map.tiles", &json!({})).unwrap(); let at = |s: &mut Session, name: &str| tiles(s).as_array().unwrap().iter().position(|t| t["name"] == name).unwrap(); let (steps, dirty) = (s.undo_depth(), s.dirty()); s.run("map.selectTiles", &json!({"name": "Base"})).unwrap(); assert_eq!((s.undo_depth(), s.dirty()), (steps, dirty), "no undo step, nothing to save"); assert_eq!((s.active_layer, s.selected_tile()), (None, Some(at(&mut s, "Base")))); let listed = tiles(&mut s); let picked: Vec<_> = listed.as_array().unwrap().iter().filter(|t| t["selected"] == true).map(|t| t["name"].clone()).collect(); assert_eq!(picked, [json!("Base")], "{listed}"); let v = identify(&mut s, "selectedInContents"); assert_eq!(asked(&v), ["Base"], "{v}"); assert!(v.get("results").is_none(), "{v}"); // Only Selected in Layers asks it alone. assert!(identify(&mut s, "selectable").is_null()); assert_eq!(asked(&identify(&mut s, "visible")), ["Roads"]); // Picking another ends the first pick; moved, it stays picked. s.run("map.selectTiles", &json!({"name": "Roads"})).unwrap(); assert_eq!(asked(&identify(&mut s, "selectedInContents")), ["Roads"]); let before = at(&mut s, "Roads"); s.run("map.setTiles", &json!({"name": "Roads", "move": "bottom"})).unwrap(); assert_ne!(at(&mut s, "Roads"), before, "moved"); assert_eq!(s.selected_tile(), Some(at(&mut s, "Roads"))); assert_eq!(s.map().tiles.iter().filter(|t| t.selected).count(), 1); assert!(s.run("map.selectTiles", &json!({"name": "Nope"})).is_err()); assert!(s.run("map.selectTiles", &json!({})).is_err()); // Undo and redo leave the pick as it is: picking isn't a step of theirs. s.run("map.setTiles", &json!({"name": "Base", "opacity": 0.5})).unwrap(); s.run("map.selectTiles", &json!({"name": "Base"})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.map().tiles.iter().find(|t| t.name == "Base").map(|t| t.opacity), Some(1.0), "undone"); assert_eq!(s.selected_tile(), Some(at(&mut s, "Base"))); s.run("map.selectTiles", &json!({"name": "Roads"})).unwrap(); s.run("edit.redo", &json!({})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.selected_tile(), Some(at(&mut s, "Roads"))); assert_eq!(s.map().tiles.iter().filter(|t| t.selected).count(), 1); // Renamed and picked, then the rename undone: the one layer at its address stays picked. s.run("map.setTiles", &json!({"name": "Base", "rename": "Base 2"})).unwrap(); s.run("map.selectTiles", &json!({"name": "Base 2"})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.selected_tile(), Some(at(&mut s, "Base"))); // An overlay at the same address and of the same kind isn't taken for it: the role tells. s.run("map.addTiles", &json!({"url": base, "name": "Twin", "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": "Base", "rename": "Base 2"})).unwrap(); s.run("map.selectTiles", &json!({"name": "Base 2"})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.selected_tile(), Some(at(&mut s, "Base"))); s.run("map.selectTiles", &json!({"name": "Twin"})).unwrap(); s.run("map.setTiles", &json!({"name": "Base", "opacity": 0.2})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.selected_tile(), Some(at(&mut s, "Twin"))); // With the same name too. s.run("map.setTiles", &json!({"name": "Twin", "rename": "Base"})).unwrap(); let base_layer = s.map().tiles.iter().position(|t| t.name == "Base" && !t.overlay); s.run("map.selectTiles", &json!({"index": base_layer})).unwrap(); s.run("map.setTiles", &json!({"index": base_layer, "opacity": 0.3})).unwrap(); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.selected_tile(), s.map().tiles.iter().position(|t| t.name == "Base" && !t.overlay)); // A map layer made active ends it, and no active layer later brings it back. s.run("layer.select", &json!({"layer": points})).unwrap(); assert_eq!(s.selected_tile(), None); s.run("layer.remove", &json!({"layer": points})).unwrap(); assert_eq!((s.active_layer, s.selected_tile()), (None, None)); assert!(identify(&mut s, "selectedInContents").is_null()); } /// A service whose answer in one format is its error (swisstopo's MapServer fails to write GeoJSON /// for some layers, its GML answers) is asked again in the next format it offers; once that one /// answers, the layer keeps asking in it. Without the request or the point, after an error no /// format changes, or with no format left, the error is told. #[test] fn identify_asks_again_in_the_next_format_after_the_services_error() { const WMS: &str = r#" Test WMS image/png application/jsonapplication/json; subtype=geojsonapplication/vnd.ogc.gml RootEPSG:3857linesLines"#; const FAILED: &str = r#" msOGRWriteFromQuery(): General error message."#; const GML: &str = r#"thrustThrust"#; let added = || { let mut s = Session::new(Project::default()); s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "lines", "text": WMS})).unwrap(); s.run("map.goToXY", &json!({"lon": 8.73, "lat": 47.5, "scale": 10000})).unwrap(); s }; let ask = |s: &mut Session| { let v = s.run("identify", &json!({"lon": 8.73, "lat": 47.5, "mode": "visible"})).unwrap(); v["featureInfo"][0]["url"].as_str().unwrap().to_string() }; let answer = |text: &str, url: &str| json!({"layer": "Lines", "text": text, "url": url, "lon": 8.73, "lat": 47.5}); let mut s = added(); let first = ask(&mut s); assert!(first.contains("&INFO_FORMAT=application%2Fjson&"), "{first}"); // Without the request or the point, the error is told as before. let e = s.run("identify.featureInfo", &json!({"layer": "Lines", "text": FAILED})).unwrap_err(); assert!(e.starts_with("Lines: the service answered with an error: msOGRWriteFromQuery()"), "{e}"); assert!(s.run("identify.featureInfo", &json!({"layer": "Lines", "text": FAILED, "url": first})).is_err()); // With them, the request in GML (GeoJSON's other name is left out too); the layer keeps its // own until GML answers, so the same error again leads to GML again. let r = s.run("identify.featureInfo", &answer(FAILED, &first)).unwrap(); let gml = r["url"].as_str().unwrap().to_string(); assert!(gml.contains("&INFO_FORMAT=application%2Fvnd.ogc.gml&"), "{r}"); assert_eq!(r["tileLayer"], "Lines"); assert_eq!(ask(&mut s), first); assert_eq!(s.run("identify.featureInfo", &answer(FAILED, &first)).unwrap()["url"], gml.as_str()); let r = s.run("identify.featureInfo", &answer(GML, &gml)).unwrap(); assert_eq!(r["results"][0]["layer"], "Lines › thrust", "{r}"); assert_eq!(r["results"][0]["attributes"]["kind_desc"], "Thrust"); assert_eq!(ask(&mut s), gml); // The last format's error is told, and the layer keeps asking in it; a late GeoJSON answer // doesn't bring it back. let e = s.run("identify.featureInfo", &answer(FAILED, &gml)).unwrap_err(); assert!(e.contains("msOGRWriteFromQuery()"), "{e}"); s.run("identify.featureInfo", &answer(GML, &first)).unwrap(); assert_eq!(ask(&mut s), gml); // An error no format changes, or an answer that isn't the service's error, isn't retried. let mut t = added(); let not_queryable = r#"no"#; assert!(t.run("identify.featureInfo", &answer(not_queryable, &first)).is_err()); assert!(t.run("identify.featureInfo", &answer("{oops", &first)).is_err()); assert_eq!(ask(&mut t), first); // The project file is as before. let project = serde_json::to_string(&s.project).unwrap(); assert!(!project.contains("featureInfo") && !project.contains("vnd.ogc.gml")); } /// Services from their capabilities: a WMS layer becomes a tile layer, an OGC API collection's /// features are downloaded (from a local server); unknown layers and documents are refused. #[test] fn services_list_and_add_layers() { const WMS: &str = r#" Test WMS image/png RootEPSG:3857 roadsRoads5154555 "#; let mut s = Session::new(Project::default()); let url = "https://maps.example.com/wms"; let v = s.run("service.layers", &json!({"url": url, "text": WMS})).unwrap(); assert_eq!((v["kind"].as_str(), v["count"].as_u64()), (Some("WMS"), Some(1))); assert_eq!(v["layers"][0]["offer"]["type"], "tiles"); s.run("service.add", &json!({"url": url, "layer": "Roads", "text": WMS})).unwrap(); let t = s.map().tiles.first().unwrap(); assert!(t.url.contains("LAYERS=roads") && t.url.contains("{bbox-epsg-3857}") && t.overlay, "{}", t.url); // Returning to a service layer's extent still works after saving and reopening the project. let project = serde_json::to_string(&s.project).unwrap(); let mut reopened = Session::new(serde_json::from_str(&project).unwrap()); reopened.run("map.goToXY", &json!({"lon": -100, "lat": 0, "scale": 1000000})).unwrap(); let r = reopened.run("map.zoomToTiles", &json!({"name": "Roads"})).unwrap(); assert_eq!(r["bounds"], json!([5.0, 45.0, 15.0, 55.0])); let center = reopened.map().center_lonlat(); assert!((center.x - 10.0).abs() < 0.1 && (center.y - 50.0).abs() < 0.5, "{center:?}"); assert!(s.run("service.add", &json!({"url": url, "layer": "nope", "text": WMS})).is_err()); assert!(s.run("service.layers", &json!({"url": url, "text": "no"})).is_err()); assert!(s.run("service.layers", &json!({"url": "file:///etc/x", "text": WMS})).is_err()); // An OGC API collection's features, downloaded from a local server. const GEOJSON: &[u8] = br#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"name": "Lake"}, "geometry": {"type": "Point", "coordinates": [10, 50]}}]}"#; let base = serve(GEOJSON, 1); let api = format!("{base}/collections"); let collections = format!( r#"{{"collections": [{{"id": "lakes", "title": "Lakes", "links": [{{"rel": "items", "type": "application/geo+json", "href": "{base}/collections/lakes/items"}}]}}]}}"# ); let r = s.run("service.add", &json!({"url": api, "layer": "lakes", "text": collections})).unwrap(); assert_eq!(r["features"], 1); assert!(s.map().layer_by_name("Lakes").is_some()); // A document on the web can't send RustGIS into the local network. let inward = WMS.replace("https://maps.example.com/wms?", "http://127.0.0.1:8080/wms?"); let v = s.run("service.layers", &json!({"url": url, "text": inward})).unwrap(); assert_eq!(v["count"], 0); assert!(v["notes"].as_array().unwrap().iter().any(|n| n.as_str().is_some_and(|n| n.contains("won't fetch"))), "{v}"); assert!(s.run("service.add", &json!({"url": url, "layer": "roads", "text": inward})).is_err()); // A service's answer with latitude first (it misses the collection's extent, and fits it // turned round) is turned round, and a page of a larger collection is pointed out. const LAT_FIRST: &[u8] = br#"{"type": "FeatureCollection", "numberMatched": 9, "numberReturned": 1, "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [50, 10]}}], "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:EPSG::4326"}}}"#; let base = serve(LAT_FIRST, 1); let collections = format!( r#"{{"collections": [{{"id": "wells", "title": "Wells", "extent": {{"spatial": {{"bbox": [[9, 49, 11, 51]]}}}}, "links": [{{"rel": "items", "href": "{base}/collections/wells/items"}}]}}]}}"# ); s.run("service.add", &json!({"url": format!("{base}/collections"), "layer": "wells", "text": collections})).unwrap(); let e = s.map().layer_by_name("Wells").unwrap().extent(); assert!((e.min_x - 10.0).abs() < 1e-9 && (e.min_y - 50.0).abs() < 1e-9, "{e:?}"); assert!(s.notices.iter().any(|n| n.text.contains("1 of its 9 features"))); // An OGC API layer added with its tileset's advertised template (the app reads it first); a // given template that isn't one, or points inward, is refused. let tiled = r#"{"collections": [{"id": "relief", "links": [{"rel": "tilesets-map", "href": "https://x.org/relief/map/tiles"}]}]}"#; let api = "https://x.org/collections"; let r = s .run( "service.add", &json!({"url": api, "layer": "relief (map tiles)", "text": tiled, "tiles": "https://x.org/relief/map/tiles/smerc/{z}/{y}/{x}.png"}), ) .unwrap(); assert!(s.map().tiles.iter().any(|t| t.url == "https://x.org/relief/map/tiles/smerc/{z}/{y}/{x}.png"), "{r}"); for bad in ["https://x.org/relief.png", "http://169.254.169.254/{z}/{x}/{y}", "http://127.0.0.1/{z}/{x}/{y}"] { assert!(s.run("service.add", &json!({"url": api, "layer": "relief (map tiles)", "text": tiled, "tiles": bad})).is_err(), "{bad}"); } } /// Adding a layer leaves the view where it is once the map holds something (a service layer, a /// data layer over a tile overlay — hidden or not — or after a COG): only the first layer on an /// empty map is framed, an empty group holding nothing. #[test] fn adding_a_layer_keeps_the_view() { const WMS: &str = r#" Test WMS image/png RootEPSG:3857 roadsRoads5154555 "#; let url = "https://maps.example.com/wms"; let mut s = Session::new(Project::default()); // The first layer on an empty map is framed. s.run("service.add", &json!({"url": url, "layer": "roads", "text": WMS})).unwrap(); let c = s.map().center_lonlat(); assert!((c.x - 10.0).abs() < 0.1 && (c.y - 50.0).abs() < 0.5, "{c:?}"); // A later one leaves the view where it was put. s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = s.map().camera; s.run("service.add", &json!({"url": url, "layer": "roads", "text": WMS, "name": "Roads again"})).unwrap(); assert_eq!(s.map().camera, before); // A data layer over a tile overlay is no first layer either. let far = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}}]}"#; let mut t = Session::new(Project::default()); t.run("map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "overlay": true})).unwrap(); t.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = t.map().camera; t.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); assert_eq!(t.map().camera, before); // A hidden overlay is content too: the user hid it after choosing a view, so data added to a // map that shows only its basemap keeps that view. let mut h = Session::new(Project::default()); h.run("map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "overlay": true})).unwrap(); h.run("map.setTiles", &json!({"index": 0, "visible": false})).unwrap(); h.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = h.map().camera; h.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); assert_eq!(h.map().camera, before); // An archive is content too, whether it draws as a basemap or as data: a data layer added // over a PMTiles basemap keeps the view. let dir = std::env::temp_dir().join(format!("rustgis-archive-content-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let archive = dir.join("iceland.pmtiles"); std::fs::write(&archive, empty_pmtiles([-24.5, 63.3, -13.5, 66.6])).unwrap(); let mut a = Session::new(Project::default()); a.run("map.addTiles", &json!({"url": archive.display().to_string()})).unwrap(); a.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = a.map().camera; a.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); assert_eq!(a.map().camera, before); let _ = std::fs::remove_dir_all(&dir); // A cloud-optimised GeoTIFF read in place is content too, though it is a tile layer: data // added after it keeps the view. let file = rustgis_data::raster::cog::example(512, 128); let (base, _) = serve_ranges(file); let url = format!("{base}/scene.tif"); let mut c = Session::new(Project::default()); c.run("layer.addUrl", &json!({"url": url})).unwrap(); // The first one is framed on the file: 10–11° E, 45–46° N. let at = c.map().center_lonlat(); assert!((at.x - 10.5).abs() < 0.1 && (at.y - 45.5).abs() < 0.5, "{at:?}"); c.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = c.map().camera; c.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); assert_eq!(c.map().camera, before); // A later COG without `"zoom"` leaves the view alone too. c.run("layer.addUrl", &json!({"url": url, "name": "Second"})).unwrap(); assert_eq!(c.map().camera, before); // `"zoom": false` keeps the view of a first COG; `"zoom": true` frames a later one. let mut z = Session::new(Project::default()); let start = z.map().camera; z.run("layer.addUrl", &json!({"url": url, "zoom": false})).unwrap(); assert_eq!(z.map().camera, start); z.run("layer.addUrl", &json!({"url": url, "name": "Again", "zoom": true})).unwrap(); let at = z.map().center_lonlat(); assert!((at.x - 10.5).abs() < 0.1 && (at.y - 45.5).abs() < 0.5, "{at:?}"); // A raster added while the map holds only an overlay keeps the view too: `add_raster_after` // (shared by analysis outputs) counts the overlay as content. use base64::Engine; let raster = rustgis_data::raster::Raster::new( "dem", 8, 8, vec![vec![3.0; 64]], rustgis_data::raster::GeoTransform { origin_x: 5.0, origin_y: 50.0, pixel_width: 0.1, pixel_height: -0.1 }, rustgis_data::raster::Crs::LonLat, None, rustgis_data::raster::ColorHint::Gray, "Float32", ) .unwrap(); let tif = rustgis_data::raster::geotiff::write(&raster, 0).unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(&tif); let mut t = Session::new(Project::default()); t.run("map.addTiles", &json!({"url": "https://tiles.example.com/{z}/{x}/{y}.png", "overlay": true})).unwrap(); t.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = t.map().camera; t.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": b64})).unwrap(); assert_eq!(t.map().camera, before); // An analysis output adds through the same `add_raster_after`: it leaves the view too. t.run("raster.function", &json!({"layer": "dem", "function": "slope"})).unwrap(); assert_eq!(t.map().camera, before); // A group holding no layer holds nothing: the first data layer added is framed. let mut g = Session::new(Project::default()); g.run("group.add", &json!({"name": "Empty"})).unwrap(); g.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); let at = g.map().center_lonlat(); assert!((at.x - 151.2).abs() < 0.1 && (at.y + 33.9).abs() < 0.5, "{at:?}"); } /// An OGC API collection that comes in pages is read page by page, and a `next` link is followed /// only where the page may point. #[test] fn ogc_api_features_follow_next_pages() { let page = |ids: &[u32], next: Option<&str>| { let features: Vec = ids .iter() .map(|&i| json!({"type": "Feature", "properties": {"n": i}, "geometry": {"type": "Point", "coordinates": [i, 1]}})) .collect(); let mut p = json!({"type": "FeatureCollection", "numberMatched": 5, "numberReturned": ids.len(), "features": features}); if let Some(h) = next { p["links"] = json!([{"rel": "next", "type": "application/geo+json", "href": h}]); } p.to_string() }; let base = serve_routes( vec![ ("/collections/wells/items?page=2", page(&[3, 4], Some("{base}/collections/wells/items?page=3"))), ("/collections/wells/items?page=3", page(&[5], None)), ("/collections/wells/items", page(&[1, 2], Some("items?page=2"))), ("/collections/inward/items", page(&[1, 2], Some("http://169.254.169.254/latest/meta-data/"))), ], 4, ); let collections = format!( r#"{{"collections": [{{"id": "wells", "title": "Wells", "links": [{{"rel": "items", "href": "{base}/collections/wells/items"}}]}}, {{"id": "inward", "title": "Inward", "links": [{{"rel": "items", "href": "{base}/collections/inward/items"}}]}}]}}"# ); let mut s = Session::new(Project::default()); let api = format!("{base}/collections"); let r = s.run("service.add", &json!({"url": api, "layer": "wells", "text": collections})).unwrap(); assert_eq!(r["features"], 5); assert!(!s.notices.iter().any(|n| n.text.contains("aren't read")), "{:?}", s.notices.iter().map(|n| &n.text).collect::>()); let r = s.run("service.add", &json!({"url": api, "layer": "inward", "text": collections})).unwrap(); assert_eq!(r["features"], 2); assert!(s.notices.iter().any(|n| n.text.starts_with("Inward: ") && n.text.contains("never fetched"))); } /// An ArcGIS layer's description is read first: a layer that pages is read in pages ordered by its /// object ids, from the start, and its query as it is (whose first page, in the server's own /// order, may hold other features than the ordered first: Hawaii's zoning did) isn't fetched; a /// layer that can't page keeps its first page, and says so. #[test] fn arcgis_features_read_past_the_record_limit() { use ogc_server::{Route, Server, arcgis_layer, arcgis_page}; let q = "where=1%3D1&outFields=*&returnGeometry=true&outSR=4326&f=geojson"; let parcels = "/arcgis/rest/services/P/MapServer/0"; let page = |offset: u32| format!("{parcels}/query?{q}&resultOffset={offset}&resultRecordCount=2&orderByFields=OBJECTID"); let server = Server::new(vec![ // The first page in the server's own order: two of the five, not the ordered first two // (never asked for). Route::new(&format!("{parcels}/query?{q}"), &arcgis_page(&[3, 0], true)), Route::new(parcels, &arcgis_layer(2, true)), Route::new(&page(0), &arcgis_page(&[0, 1], true)), Route::new(&page(2), &arcgis_page(&[2, 3], true)), Route::new(&page(4), &arcgis_page(&[4], false)), Route::new("/arcgis/rest/services/P/MapServer/1/query", &arcgis_page(&[0, 1], true)), Route::new("/arcgis/rest/services/P/MapServer/1", &arcgis_layer(2, false)), ]); let service = r#"{"currentVersion": 11.5, "maxRecordCount": 2, "layers": [{"id": 0, "name": "Parcels"}, {"id": 1, "name": "Zoning"}]}"#; let url = format!("{}/arcgis/rest/services/P/MapServer", server.base); let mut s = Session::new(Project::default()); let r = s.run("service.add", &json!({"url": url, "layer": "Parcels", "text": service})).unwrap(); assert_eq!(r["features"], 5); let ids: Vec<_> = s.map().layer_by_name("Parcels").unwrap().dataset.features.iter().map(|f| f.attrs[0].as_f64()).collect(); assert_eq!(ids, [Some(0.0), Some(1.0), Some(2.0), Some(3.0), Some(4.0)]); assert!(!s.notices.iter().any(|n| n.text.contains("aren't read")), "{:?}", s.notices.iter().map(|n| &n.text).collect::>()); let r = s.run("service.add", &json!({"url": url, "layer": "Zoning", "text": service})).unwrap(); assert_eq!(r["features"], 2); assert!(s.notices.iter().any(|n| n.text.starts_with("Zoning: ") && n.text.contains("record limit"))); // The description, then the pages from the start; a layer that can't page, its description, // then its query as it is. let asked: Vec<_> = server .requests() .iter() .map(|r| { let offset = r.query.iter().find(|(k, _)| k == "resultOffset").map(|(_, v)| v.clone()); (r.path.trim_start_matches("/arcgis/rest/services/P/MapServer").to_string(), offset) }) .collect(); let at = |path: &str, offset: Option<&str>| (path.to_string(), offset.map(str::to_string)); assert_eq!( asked, [ at("/0", None), at("/0/query", Some("0")), at("/0/query", Some("2")), at("/0/query", Some("4")), at("/1", None), at("/1/query", None) ] ); } /// An ArcGIS layer whose description lists protocol buffers is read through service.add in `f=pbf` /// pages into one layer, known by its address; one whose first such page can't be read is read in /// GeoJSON pages instead (#1326). #[test] fn arcgis_layer_read_in_protocol_buffer_pages() { use ogc_server::{Route, Server, arcgis_page}; let q = "where=1%3D1&outFields=*&returnGeometry=true&outSR=4326"; let layer = |id: u32| format!("/arcgis/rest/services/P/MapServer/{id}"); let pbf = |id: u32, offset: u32| format!("{}/query?{q}&resultOffset={offset}&resultRecordCount=2&orderByFields=OBJECTID&f=pbf", layer(id)); let geojson = |id: u32, offset: u32| { format!("{}/query?{q}&f=geojson&resultOffset={offset}&resultRecordCount=2&orderByFields=OBJECTID", layer(id)) }; let described = json!({"currentVersion": 11.5, "maxRecordCount": 2, "supportedQueryFormats": "JSON, geoJSON, PBF", "advancedQueryCapabilities": {"supportsPagination": true}, "fields": [{"name": "OBJECTID", "type": "esriFieldTypeOID"}]}) .to_string(); let server = Server::new(vec![ Route::new(&layer(0), &described), Route::bytes(&pbf(0, 0), esripbf_answers::page(&[0, 1], true, "OBJECTID")), Route::bytes(&pbf(0, 2), esripbf_answers::page(&[2, 3], true, "OBJECTID")), Route::bytes(&pbf(0, 4), esripbf_answers::page(&[4], false, "OBJECTID")), // A layer that lists protocol buffers, but answers its first such page with an error. Route::new(&layer(1), &described), Route::new(&pbf(1, 0), r#"{"error": {"code": 400, "message": "Invalid query"}}"#), Route::new(&geojson(1, 0), &arcgis_page(&[0, 1], true)), Route::new(&geojson(1, 2), &arcgis_page(&[2], false)), ]); let service = r#"{"currentVersion": 11.5, "maxRecordCount": 2, "layers": [{"id": 0, "name": "Parcels"}, {"id": 1, "name": "Zoning"}]}"#; let url = format!("{}/arcgis/rest/services/P/MapServer", server.base); let mut s = Session::new(Project::default()); let r = s.run("service.add", &json!({"url": url, "layer": "Parcels", "text": service})).unwrap(); assert_eq!(r["features"], 5); let parcels = s.map().layer_by_name("Parcels").unwrap(); let ids: Vec<_> = parcels.dataset.features.iter().map(|f| f.attrs[0].as_f64()).collect(); assert_eq!(ids, [Some(0.0), Some(1.0), Some(2.0), Some(3.0), Some(4.0)]); assert!(parcels.dataset.source.as_deref().is_some_and(|u| u.contains("/MapServer/0/query?")), "{:?}", parcels.dataset.source); let r = s.run("service.add", &json!({"url": url, "layer": "Zoning", "text": service})).unwrap(); assert_eq!(r["features"], 3); assert!(s.map().layer_by_name("Zoning").is_some()); // The layer read in protocol buffers says nothing; the one read in GeoJSON says why. let texts: Vec<_> = s.notices.iter().map(|n| n.text.as_str()).collect(); assert!(!texts.iter().any(|t| t.contains("aren't read") || t.starts_with("Parcels")), "{texts:?}"); assert!( texts .iter() .any(|t| t.starts_with("Zoning: its protocol buffer pages couldn't be read (") && t.ends_with("so it is read in GeoJSON")), "{texts:?}" ); // Each layer's description, then its pages: in protocol buffers, or in GeoJSON after the one // refused. let asked: Vec<_> = server .requests() .iter() .map(|r| { let at = |k: &str| r.query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()).unwrap_or_default(); format!("{} {} {}", r.path.trim_start_matches("/arcgis/rest/services/P/MapServer"), at("f"), at("resultOffset")) }) .collect(); assert_eq!( asked, [ "/0 json ", "/0/query pbf 0", "/0/query pbf 2", "/0/query pbf 4", "/1 json ", "/1/query pbf 0", "/1/query geojson 0", "/1/query geojson 2" ] ); } /// An ArcGIS layer's description, read before its features, doesn't count against their budget: /// features that fit it alone are read whole (#1326). #[test] fn arcgis_description_leaves_the_features_their_budget() { use ogc_server::{Route, Server, arcgis_layer, arcgis_page}; let layer = "/arcgis/rest/services/P/MapServer/1"; let answer = arcgis_page(&[0, 1], false); let server = Server::new(vec![Route::new(&format!("{layer}/query"), &answer), Route::new(layer, &arcgis_layer(2, false))]); let url = format!("{}{layer}/query?where=1%3D1&outFields=*&f=geojson", server.base); let mut s = Session::new(Project::default()); let bytes = crate::cmd::service::download_features(&mut s, "Zoning", &url, answer.len()).unwrap(); let read: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(read["features"].as_array().map(Vec::len), Some(2)); let asked: Vec<_> = server.requests().iter().map(|r| r.path.trim_start_matches(layer).to_string()).collect(); assert_eq!(asked, ["", "/query"]); // A byte less and the features don't fit. assert!(crate::cmd::service::download_features(&mut s, "Zoning", &url, answer.len() - 1).is_err()); } /// An ArcGIS layer's description larger than its cap is given up on: the layer's query is /// fetched as it is, its features read whole (#1326). #[test] fn arcgis_description_past_its_cap_leaves_the_query_as_it_is() { use ogc_server::{Route, Server, arcgis_page}; let layer = "/arcgis/rest/services/P/MapServer/1"; // A layer that pages, padded past the cap: read, it would be asked for in pages. let described = json!({"currentVersion": 11.5, "maxRecordCount": 2, "advancedQueryCapabilities": {"supportsPagination": true}, "fields": [{"name": "OBJECTID", "type": "esriFieldTypeOID"}], "description": "x".repeat(rustgis_data::ogc::DESCRIPTION_MAX)}) .to_string(); let server = Server::new(vec![Route::new(&format!("{layer}/query"), &arcgis_page(&[0, 1], false)), Route::new(layer, &described)]); let url = format!("{}{layer}/query?where=1%3D1&outFields=*&f=geojson", server.base); let mut s = Session::new(Project::default()); let max = crate::render::rustgis_tiles::cache::MAX_DOWNLOAD; let bytes = crate::cmd::service::download_features(&mut s, "Zoning", &url, max).unwrap(); let read: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(read["features"].as_array().map(Vec::len), Some(2)); let asked: Vec<_> = server .requests() .iter() .map(|r| (r.path.trim_start_matches(layer).to_string(), r.query.iter().any(|(k, _)| k == "resultOffset"))) .collect(); assert_eq!(asked, [(String::new(), false), ("/query".to_string(), false)]); } /// WFS 2.0 without next links reads every page through service.add, with no loss notice. #[test] fn wfs_service_reads_offset_pages_without_next_links() { use ogc_server::{Route, Server}; let page = |ids: &[u32]| { json!({"type": "FeatureCollection", "features": ids.iter().map(|id| { json!({"type": "Feature", "id": id, "properties": {"n": id}, "geometry": {"type": "Point", "coordinates": [id, 1]}}) }).collect::>()}) .to_string() }; let query = "SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature&TYPENAMES=roads"; let format = "OUTPUTFORMAT=application%2Fjson&SRSNAME=EPSG:4326"; let server = Server::new(vec![ Route::new(&format!("/wfs?{query}&{format}&COUNT=50000"), &page(&[1, 2])), Route::new(&format!("/wfs?{query}&SRSNAME=EPSG:4326&RESULTTYPE=hits"), r#""#), Route::new(&format!("/wfs?{query}&{format}&STARTINDEX=2&COUNT=2"), &page(&[3])), ]); let caps = format!( r#"roadsRoads"#, server.base ); let mut s = Session::new(Project::default()); let result = s.run("service.add", &json!({"url": format!("{}/wfs", server.base), "layer": "roads", "text": caps})).unwrap(); assert_eq!(result["features"], 3); assert!(s.notices.is_empty(), "{:?}", s.notices.iter().map(|n| &n.text).collect::>()); assert_eq!(server.requests().len(), 3); } /// A feature layer comes whole unless an area is asked for (#819): then only the features that /// cross it, a WFS 1.1 or 2.0 asked with `BBOX` latitude first in urn:ogc:def:crs:EPSG::4326. A /// WFS 1.0, or an area across the antimeridian, is asked whole, and says so. #[test] fn service_features_asked_for_over_an_area() { use ogc_server::{Route, Server}; let features = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [-42.8, -5.1]}}]}"#; let server = Server::new(vec![Route::new("/wfs", features)]); let caps = |version: &str| { format!( r#" CGMAT:municMunicipalitiesEPSG:4674"#, server.base ) }; let url = format!("{}/wfs", server.base); let piaui = json!([-45.99, -10.93, -40.37, -2.74]); let mut s = Session::new(Project::default()); let add = |s: &mut Session, version: &str, bbox: Option| { let mut p = json!({"url": url, "layer": "CGMAT:munic", "text": caps(version), "name": format!("M{}", s.map().layers.len())}); if let Some(b) = bbox { p["bbox"] = b; } s.run("service.add", &p).unwrap() }; let r = add(&mut s, "1.1.0", Some(piaui.clone())); assert_eq!(r["features"], 1); assert_eq!(r["area"], piaui); // The layer keeps the service address it came from, not its download's file name: its // GetFeature request for this type, with the area's box added. let source = s.map().layer_by_name("M0").and_then(|l| l.dataset.source.clone()).unwrap(); let requested = url::Url::parse(&source).unwrap(); let query: std::collections::HashMap = requested.query_pairs().into_owned().collect(); assert_eq!(query.get("SERVICE").map(String::as_str), Some("WFS"), "{source}"); assert_eq!(query.get("VERSION").map(String::as_str), Some("1.1.0"), "{source}"); assert_eq!(query.get("REQUEST").map(String::as_str), Some("GetFeature"), "{source}"); assert_eq!(query.get("TYPENAME").map(String::as_str), Some("CGMAT:munic"), "{source}"); assert_eq!(query.get("BBOX").map(String::as_str), Some("-10.93,-45.99,-2.74,-40.37,urn:ogc:def:crs:EPSG::4326"), "{source}"); let r = add(&mut s, "2.0.0", Some(json!("view"))); assert!(r["area"].is_array(), "{r}"); let r = add(&mut s, "2.0.0", None); assert!(r.get("area").is_none(), "{r}"); add(&mut s, "2.0.0", Some(json!("full"))); add(&mut s, "2.0.0", Some(json!([170.0, -20.0, -170.0, -10.0]))); add(&mut s, "1.0.0", Some(piaui.clone())); // WFS 2.0 also asks for hits; those requests must preserve the feature query's box. let requests = server.requests(); let is_hits = |r: &&ogc_server::Request| r.query.iter().any(|(k, v)| k == "RESULTTYPE" && v == "hits"); let bbox = |r: &ogc_server::Request| r.query.iter().find(|(k, _)| k == "BBOX").map(|(_, v)| v.clone()); let boxes: Vec> = requests.iter().filter(|r| !is_hits(r)).map(bbox).collect(); let hit_boxes: Vec> = requests.iter().filter(is_hits).map(bbox).collect(); assert_eq!(hit_boxes, boxes[1..5]); assert_eq!(boxes.len(), 6); assert_eq!(boxes[0].as_deref(), Some("-10.93,-45.99,-2.74,-40.37,urn:ogc:def:crs:EPSG::4326")); assert!(boxes[1].as_deref().is_some_and(|b| b.ends_with(",urn:ogc:def:crs:EPSG::4326")), "{boxes:?}"); assert_eq!(&boxes[2..], [None, None, None, None]); let notes: Vec<&str> = s.notices.iter().map(|n| n.text.as_str()).collect(); assert!(notes.iter().any(|n| n.starts_with("M4: an area across the antimeridian")), "{notes:?}"); assert!(notes.iter().any(|n| n.starts_with("M5: its request can't carry an area")), "{notes:?}"); // Asked for the box given, the map goes to it, not to the whole layer's bounds. s.run("service.add", &json!({"url": url, "layer": "CGMAT:munic", "text": caps("2.0.0"), "name": "Piauí", "bbox": piaui})).unwrap(); let v = view_lonlat(&s); assert!(v[0] < -45.0 && v[2] > -41.0 && v[1] < -10.0 && v[3] > -3.0 && v[2] - v[0] < 20.0, "{v:?}"); // An area asked for as the view keeps the view, even on an empty map: the features returned // are only those crossing it. let mut k = Session::new(Project::default()); k.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = k.map().camera; k.run("service.add", &json!({"url": url, "layer": "CGMAT:munic", "text": caps("2.0.0"), "bbox": "view"})).unwrap(); assert_eq!(k.map().camera, before); } /// The map's view in longitude/latitude, as `bbox: "view"` reads it. fn view_lonlat(s: &Session) -> [f64; 4] { let e = crate::cmd::workspace::view_extent(s); [e.min_x, e.min_y, e.max_x, e.max_y] } /// A layer past the first 2000 of a long list is added by its name, and the list says it stops /// (#1519: IBGE's WFS has 10,186 types, and its order changes between answers). #[test] fn a_layer_past_the_listing_is_added_by_name() { use ogc_server::{Route, Server}; let answer = r#"{"type":"FeatureCollection","features":[{"type":"Feature","id":"a.1","geometry":{"type":"Point","coordinates":[1,1]},"properties":{}}]}"#; let server = Server::new(vec![Route::new("/wfs", answer)]); let url = format!("{}/wfs", server.base); let caps = |n: usize| { let types: String = (1..=n).map(|i| format!("t:{i}")).collect(); format!( r#"{types}"#, server.base ) }; let mut s = Session::new(Project::default()); let whole = s.run("service.layers", &json!({"url": url, "text": caps(2000)})).unwrap(); assert_eq!(whole["count"], 2000); assert_eq!(whole["notes"], json!([])); assert!(whole.get("total").is_none() && whole.get("listedNote").is_none()); let cut = s.run("service.layers", &json!({"url": url, "text": caps(2001)})).unwrap(); assert_eq!(cut["total"], 2001); assert_eq!(cut["listedNote"], cut["notes"][0]); assert!(cut.get("atLeast").is_none()); assert_eq!(cut["count"], 2000); assert_eq!(cut["layers"].as_array().unwrap().len(), 2000); assert_eq!(cut["layers"][1999]["name"], "t:2000"); assert_eq!(cut["notes"], json!(["only the first 2000 of 2001 layers are listed; service.add takes any of them by name"])); s.run("service.add", &json!({"url": url, "layer": "t:2001", "text": caps(2001), "name": "Last"})).unwrap(); assert!(s.map().layer_by_name("Last").is_some()); assert!(server.requests().iter().any(|r| r.query.iter().any(|(k, v)| k == "TYPENAMES" && v == "t:2001"))); // A name it doesn't have isn't sent to a list said to hold them all. let e = s.run("service.add", &json!({"url": url, "layer": "t:0", "text": caps(2001)})).unwrap_err(); assert!(e.ends_with("no layer 't:0' (service.layers lists the first 2000 of 2001 layers)"), "{e}"); // A service group takes all of an OGC API's collections, so it still refuses so long a list. let collections: Vec = (1..=2001) .map(|i| json!({"id": format!("c{i}"), "links": [{"rel": "items", "href": format!("collections/c{i}/items")}]})) .collect(); let text = json!({"collections": collections}).to_string(); let url = "https://api.example.org/collections"; let listed = s.run("service.layers", &json!({"url": url, "text": text})).unwrap(); assert_eq!(listed["count"], 2000); let e = s.run("service.groupPlan", &json!({"url": url, "text": text})).unwrap_err(); assert!(e.contains("more than 2000"), "{e}"); } /// Each listed layer repeats its document's address (`source_url`), which nothing bounds: a long /// one lists fewer layers, said, and `service.add`'s refusal of a name counts those listed. #[test] fn a_long_document_address_lists_fewer_layers() { let collections: Vec = (1..=600) .map(|i| json!({"id": format!("c{i}"), "links": [{"rel": "items", "href": format!("https://api.example.org/c{i}/items")}]})) .collect(); let text = json!({"collections": collections}).to_string(); let mut s = Session::new(Project::default()); let short = s.run("service.layers", &json!({"url": "https://api.example.org/collections", "text": text})).unwrap(); // (Other notes, such as how many features a collection is read up to here, aren't these.) let says = |out: &Value, text: &str| out["notes"].as_array().unwrap().iter().any(|n| n == text); assert_eq!(short["count"], 600); assert!(short.get("listedNote").is_none() && !short["notes"].to_string().contains("repeats"), "{}", short["notes"]); let url = format!("https://api.example.org/{}/collections", "a".repeat(16 << 10)); let long = s.run("service.layers", &json!({"url": url, "text": text})).unwrap(); let bytes = long["layers"][0]["source_url"].as_str().unwrap().len(); // The test build lists 8 MiB of addresses (its length doesn't divide them). let listed = (8 << 20) / bytes; assert!(listed < 600 && (8 << 20) % bytes != 0, "{bytes}"); assert_eq!((&long["count"], &long["total"]), (&json!(listed), &json!(600))); let cut = format!("only the first {listed} of 600 layers are listed; service.add takes any of them by name"); assert_eq!(long["listedNote"], json!(cut)); let why = format!( "each listed layer repeats its document's address, {bytes} bytes long here, and RustGIS lists at most 8 MiB of them (at least one layer)" ); assert!(says(&long, &why) && says(&long, &cut), "{}", long["notes"]); let e = s.run("service.add", &json!({"url": url, "layer": "nao", "text": text})).unwrap_err(); assert!(e.ends_with(&format!("no layer 'nao' (service.layers lists the first {listed} of 600 layers)")), "{e}"); // A service group would take them all, each with its address: it refuses the list instead. let e = s.run("service.groupPlan", &json!({"url": url, "text": text})).unwrap_err(); assert!(e.contains("an address too long to read them all"), "{e}"); assert!(s.run("service.groupPlan", &json!({"url": "https://api.example.org/collections", "text": text})).is_ok()); } /// `service.layers` with `filter` searches every layer read, not only the first 2000 it lists, and /// counts the matches (#1577: IBGE's WFS has 10,186 types, in an order that changes). #[test] fn a_filter_searches_past_the_listing() { let types: String = (1..=2500) .map(|i| { let title = if (2101..=2103).contains(&i) { format!("Solos do Brasil {i}") } else { format!("Tipo {i}") }; format!("t:{i}{title}") }) .collect(); let caps = format!(r#"{types}"#); let url = "https://wfs.example.org/wfs"; let mut s = Session::new(Project::default()); let names = |v: &Value| v["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap().to_string()).collect::>(); // Without a filter, as before: the first 2000, and no count of matches. let all = s.run("service.layers", &json!({"url": url, "text": caps})).unwrap(); assert_eq!((&all["count"], &all["total"]), (&json!(2000), &json!(2500))); assert!(all.get("matched").is_none()); // Words in the title or the name, in any case, all of them: past the listing too. let found = s.run("service.layers", &json!({"url": url, "text": caps, "filter": " BRASIL solos "})).unwrap(); assert_eq!(names(&found), ["t:2101", "t:2102", "t:2103"]); assert_eq!((&found["count"], &found["matched"], &found["total"]), (&json!(3), &json!(3), &json!(2500))); assert!(found.get("listedNote").is_none() && found["notes"].as_array().unwrap().is_empty(), "{found}"); assert_eq!(found["layers"][0]["source_url"], all["layers"][0]["source_url"]); assert_eq!(names(&s.run("service.layers", &json!({"url": url, "text": caps, "filter": "t:2500"})).unwrap()), ["t:2500"]); let none = s.run("service.layers", &json!({"url": url, "text": caps, "filter": "solos nada"})).unwrap(); assert_eq!((&none["count"], &none["matched"]), (&json!(0), &json!(0))); // More matches than a listing holds: the first 2000, said, with how many match. let many = s.run("service.layers", &json!({"url": url, "text": caps, "filter": "tipo"})).unwrap(); assert_eq!((&many["count"], &many["matched"], &many["total"]), (&json!(2000), &json!(2497), &json!(2500))); assert_eq!(many["listedNote"], "only the first 2000 of 2497 matching layers are listed; service.add takes any of them by name"); // A blank filter is none. assert!(s.run("service.layers", &json!({"url": url, "text": caps, "filter": " "})).unwrap().get("matched").is_none()); } /// A search reads again the documents a listing read, so it doesn't say again what reading them /// says: Add From Service searches once per pause in typing, and the notices would repeat (#1577). /// The answer's notes still say it. #[test] fn a_search_does_not_repeat_the_listings_notice() { // A landing page whose collections link doesn't get the address's token: one withheld. let url = "https://collections.invalid/root?f=json&token=RGSYNTH_search"; let landing = r#"{"links":[{"rel":"data","type":"application/json","href":"/catalog?f=json"}]}"#; let collections: Vec = (1..=3) .map(|i| json!({"id": format!("c{i}"), "title": format!("C {i}"), "links": [{"rel": "items", "type": "application/geo+json", "href": format!("https://collections.invalid/collections/c{i}/items")}]})) .collect(); let documents = json!([{"url": "https://collections.invalid/catalog?f=json", "text": json!({"collections": collections}).to_string()}]); let mut s = Session::new(Project::default()); let withheld = |s: &Session, mark| s.notices_since(mark).iter().filter(|n| n.text.contains("didn't get its keys")).count(); let said = |v: &Value| v["notes"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains("didn't get its keys")); let mark = s.notice_mark(); let p = json!({"url": url, "type": "ogcapi", "text": landing, "documents": documents}); let listing = s.run("service.layers", &p).unwrap(); assert_eq!(withheld(&s, mark), 1, "{:?}", s.notices_since(mark)); assert!(said(&listing), "{listing}"); for filter in ["c 1", "c 2", "c"] { let mut p = p.clone(); p["filter"] = json!(filter); let found = s.run("service.layers", &p).unwrap(); assert!(found["matched"].as_u64().unwrap() > 0 && said(&found), "{found}"); } assert_eq!(withheld(&s, mark), 1, "{:?}", s.notices_since(mark)); // A word given many times is looked for once, with the same answer. let mut many = p.clone(); many["filter"] = json!(vec!["c"; 50_000].join(" ") + " 2"); let found = s.run("service.layers", &many).unwrap(); assert_eq!(found["matched"], 1, "{found}"); // A filter that isn't text is refused, not ignored. for filter in [json!(["c"]), json!(5)] { let mut p = p.clone(); p["filter"] = filter; assert!(s.run("service.layers", &p).unwrap_err().contains("filter must be text")); } } /// A layer past what RustGIS reads of a document is refused for that reason, not as absent. #[test] fn a_layer_past_the_read_limit_is_refused_saying_so() { let wfs: String = (1..=100_001).map(|i| format!("t:{i}")).collect(); let wfs = format!(r#"{wfs}"#); let mut s = Session::new(Project::default()); let url = "https://wfs.example.org/wfs"; let listed = s.run("service.layers", &json!({"url": url, "text": wfs})).unwrap(); assert_eq!(listed["count"], 2000); // The dialog's heading and Connect's summary count those read, saying there are more. assert_eq!((&listed["total"], &listed["atLeast"]), (&json!(100_000), &json!(true))); assert_eq!( listed["notes"], json!([ "RustGIS reads at most 100000 feature types from one document: 1 left out", "only the first 2000 of the 100000 layers read are listed; service.add takes any of them by name", ]) ); let e = s.run("service.add", &json!({"url": url, "layer": "t:100001", "text": wfs})).unwrap_err(); assert!( e.contains( "no layer 't:100001' (service.layers lists the first 2000 of the 100000 layers read; RustGIS reads at most 100000 feature types" ), "{e}" ); let wcs: String = (1..=100_001).map(|i| format!("c{i}")).collect(); let wcs = format!("{wcs}"); let p = json!({"url": "https://wcs.example.org/wcs", "layer": "c100001", "text": wcs, "bbox": "full"}); let e = s.run("service.coverage", &p).unwrap_err(); assert!( e.contains( "no coverage 'c100001' (service.layers lists the first 2000 of the 100000 layers read; RustGIS reads at most 100000 coverages" ), "{e}" ); // All under one parent, as a service's layers often are: this takes minutes if each looks // through its siblings. (Its last layer is the one past the limit.) let wms: String = (1..=100_001).map(|i| format!("t{i}")).collect(); let wms = format!( r#"EPSG:3857{wms}"# ); let e = s.run("service.add", &json!({"url": "https://wms.example.org/wms", "layer": "t100001", "text": wms})).unwrap_err(); assert!(e.contains("from one document: 1 left out"), "{e}"); s.run("service.add", &json!({"url": "https://wms.example.org/wms", "layer": "t1", "text": wms})).unwrap(); // Many boxes on the parent they share: this too takes minutes if each looks through them. let boxes = r#""#.repeat(50_000); let wms: String = (1..=50_000).map(|i| format!("t{i}")).collect(); let wms = format!( r#"EPSG:3857EPSG:4326{boxes}{wms}"# ); let listed = s.run("service.layers", &json!({"url": "https://wms.example.org/wms", "text": wms})).unwrap(); assert_eq!(listed["count"], 2000); // An ArcGIS service is read as far as it is listed. let layers: Vec = (1..=2001).map(|i| json!({"id": i, "name": format!("l{i}")})).collect(); let url = "https://gis.example.org/arcgis/rest/services/P/FeatureServer"; let p = json!({"url": url, "layer": "l2001", "text": json!({"layers": layers}).to_string()}); let e = s.run("service.add", &p).unwrap_err(); assert!( e.contains("no layer 'l2001' (service.layers lists those read; RustGIS reads at most 2000 layers from one document: 1 left out)"), "{e}" ); } /// #1053: a WFS answering like GeoServer (longitude first, named urn:ogc:def:crs:EPSG::4326) is /// read as it comes; one answering latitude first (as deegree does when asked in the URN form) is /// turned round, judged against the type's `WGS84BoundingBox`. The app's download /// (`layer.addDownload` with `service` and `bounds`) is judged the same way, and left alone /// without bounds. #[test] fn wfs_axes_judged_against_the_type_bounds() { use ogc_server::{Route, Server}; let geoserver = r#"{"type":"FeatureCollection","features":[{"type":"Feature","id":"uf.11","geometry":{"type":"Point","coordinates":[-63.3,-10.8]},"properties":{"uf":"RO"}},{"type":"Feature","id":"uf.43","geometry":{"type":"Point","coordinates":[-53.6,-29.7]},"properties":{"uf":"RS"}},{"type":"Feature","id":"uf.24","geometry":{"type":"Point","coordinates":[-36.5,-5.8]},"properties":{"uf":"RN"}}],"crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:EPSG::4326"}},"bbox":[-63.3,-29.7,-36.5,-5.8]}"#; let deegree = r#"{"type":"FeatureCollection","features":[{"type":"Feature","id":"b.1","geometry":{"type":"Point","coordinates":[53.55,9.99]},"properties":{"name":"Mitte"}},{"type":"Feature","id":"b.2","geometry":{"type":"Point","coordinates":[53.46,9.98]},"properties":{"name":"Harburg"}}]}"#; let ibge = Server::new(vec![Route::new("/wfs", geoserver)]); let hamburg = Server::new(vec![Route::new("/wfs", deegree)]); let caps = |base: &str, name: &str, crs: &str, lower: &str, upper: &str| { format!( r#" {name}{name}{crs}{lower}{upper}"# ) }; let mut s = Session::new(Project::default()); let text = caps(&ibge.base, "PNADC:uf", "urn:ogc:def:crs:EPSG::4674", "-71.99 -30.51", "-34.12 1.10"); s.run("service.add", &json!({"url": format!("{}/wfs", ibge.base), "layer": "PNADC:uf", "text": text, "name": "UF"})).unwrap(); let e = s.map().layer_by_name("UF").unwrap().extent(); assert_eq!([e.min_x, e.min_y, e.max_x, e.max_y], [-63.3, -29.7, -36.5, -5.8]); // RustGIS asked for EPSG:4326, as the type doesn't list CRS84. assert!(ibge.requests().iter().all(|r| r.query.iter().any(|(k, v)| k == "SRSNAME" && v == "EPSG:4326"))); let text = caps(&hamburg.base, "app:bezirke", "urn:ogc:def:crs:EPSG::25832", "8.41 53.39", "10.34 53.97"); s.run("service.add", &json!({"url": format!("{}/wfs", hamburg.base), "layer": "app:bezirke", "text": text, "name": "Bezirke"})) .unwrap(); let e = s.map().layer_by_name("Bezirke").unwrap().extent(); assert_eq!([e.min_x, e.min_y, e.max_x, e.max_y], [9.98, 53.46, 9.99, 53.55]); // The app's download of the same answers. let download = |s: &mut Session, answer: &str, name: &str, bounds: Value| { let id = s.download_started(); assert!(s.download_landed(id, answer.as_bytes().to_vec())); let p = json!({"id": id, "url": format!("https://x.org/wfs?{name}"), "name": name, "service": true, "bounds": bounds}); s.run("layer.addDownload", &p).unwrap(); let e = s.map().layer_by_name(name).unwrap().extent(); [e.min_x, e.min_y, e.max_x, e.max_y] }; assert_eq!(download(&mut s, geoserver, "UF2", json!([-71.99, -30.51, -34.12, 1.10])), [-63.3, -29.7, -36.5, -5.8]); assert_eq!(download(&mut s, deegree, "B2", json!([8.41, 53.39, 10.34, 53.97])), [9.98, 53.46, 9.99, 53.55]); assert_eq!(download(&mut s, deegree, "B3", Value::Null), [53.46, 9.98, 53.55, 9.99]); // Bounds that aren't a box are refused, and the download is kept to be added again. let id = s.download_started(); assert!(s.download_landed(id, deegree.as_bytes().to_vec())); let bad = json!({"id": id, "url": "https://x.org/wfs", "name": "B4", "service": true, "bounds": [10.34, 53.39, 8.41, 53.97]}); assert!(s.run("layer.addDownload", &bad).unwrap_err().contains("bounds")); s.run("layer.addDownload", &json!({"id": id, "url": "https://x.org/wfs", "name": "B4", "service": true})).unwrap(); } /// Rejecting a service download without its source address leaves its bytes available for retry. #[test] fn service_downloads_require_a_url_before_consuming_bytes() { for coverage in [false, true] { let mut s = Session::new(Project::default()); let bytes = if coverage { multiband_tiff(1, 1, &[vec![1.0]], (0.0, 0.0), 1.0, None) } else { br#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[0,0]}}]}"#.to_vec() }; let id = s.download_started(); assert!(s.download_landed(id, bytes)); let mut params = json!({"id": id, "service": !coverage, "coverage": coverage, "name": "Downloaded"}); for url in [None, Some(json!(null)), Some(json!("")), Some(json!(" "))] { if let Some(url) = url { params["url"] = url; } assert!(s.run("layer.addDownload", ¶ms).unwrap_err().contains("url is required")); assert!(s.map().layers.is_empty()); } params["url"] = json!("https://example.org/service"); let result = s.run("layer.addDownload", ¶ms).unwrap(); let layer = s.map().layer(result["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(layer.dataset.source.as_deref(), Some("https://example.org/service")); } } /// A WCS coverage (from a local server) becomes a raster of the view, asked for in EPSG:4326 within /// its bounds; an error answer is refused. A catalogue's records come with the links RustGIS can /// follow, those pointing inward left out. #[test] fn wcs_coverages_and_catalogue_records() { let r = rustgis_data::raster::Raster::new( "soc", 8, 8, vec![vec![3.0; 64]], rustgis_data::raster::GeoTransform { origin_x: 5.0, origin_y: 50.0, pixel_width: 0.1, pixel_height: -0.1 }, rustgis_data::raster::Crs::LonLat, None, rustgis_data::raster::ColorHint::Gray, "Float32", ) .unwrap(); let (base, _) = serve_file(rustgis_data::raster::geotiff::write(&r, 0).unwrap(), false); let caps = String::from( r#" Soils socSoil carbon 5 49.25.8 50 "#, ); let describe = r#" 49.2 550 5.8"#; let url = format!("{base}/wcs"); let mut s = Session::new(Project::default()); let v = s.run("service.layers", &json!({"url": url, "text": caps})).unwrap(); assert_eq!((v["kind"].as_str(), v["layers"][0]["offer"]["type"].as_str()), (Some("WCS"), Some("coverage")), "{v}"); let p = json!({"url": url, "layer": "soc", "text": caps, "describe": describe, "bbox": [0, 40, 5.4, 60]}); let mut selected = p.clone(); selected["source_url"] = v["layers"][0]["source_url"].clone(); assert!(s.run("service.coverage", &selected).is_ok()); selected["source_url"] = json!("https://example.org/another-source"); assert!(s.run("service.coverage", &selected).is_err()); let c = s.run("service.coverage", &p).unwrap(); let get = c["url"].as_str().unwrap(); assert!(get.contains("SUBSET=Long(5,5.4)&SUBSET=Lat(49.2,50)&SCALESIZE=Long(1024),Lat(2048)"), "{get}"); assert_eq!(c["bbox"], json!([5.0, 49.2, 5.4, 50.0])); let r = s.run("service.add", &p).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).is_some_and(|l| l.is_raster() && l.name == "Soil carbon"), "{r}"); // The raster keeps its GetCoverage request, so Add From Service knows it as added. let source = s.map().layer(id).and_then(|l| l.dataset.source.clone()); assert!(source.as_deref().is_some_and(|src| src.contains("REQUEST=GetCoverage") && src.contains("=soc")), "{source:?}"); assert!( s.run("service.coverage", &json!({"url": url, "layer": "soc", "text": caps, "describe": describe, "bbox": [10, 10, 11, 11]})) .is_err() ); let full = s.run("service.coverage", &json!({"url": url, "layer": "soc", "text": caps, "describe": describe, "bbox": "full"})).unwrap(); assert_eq!(full["bbox"], json!([5.0, 49.2, 5.8, 50.0])); // The same coverage browsed from a saved connection: the add it lists runs as it is. s.run("connection.new", &json!({"kind": "wcs", "address": url, "name": "Soils"})).unwrap(); let browsed = s.run("connection.browse", &json!({"id": "Soils", "answer": caps})).unwrap(); let child = &browsed["children"][0]; assert_eq!((child["what"].as_str(), child["add"]["command"].as_str()), (Some("coverage"), Some("service.add")), "{browsed}"); let mut listed = child["add"]["params"].clone(); listed["text"] = json!(caps); listed["describe"] = json!(describe); listed["bbox"] = json!("full"); let layers = s.map().layers.len(); s.run("service.add", &listed).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); let (err, _) = serve_file(b"no".to_vec(), false); let p = json!({"url": format!("{err}/wcs"), "layer": "soc", "text": caps, "describe": describe, "bbox": "full"}); assert!(s.run("service.add", &p).unwrap_err().contains("error")); let records = r#" Forests https://geo.example.org/wmshttp://192.168.1.2/wfs "#; let v = s.run("catalog.search", &json!({"url": "https://cat.example.org/csw", "query": "forest", "text": records})).unwrap(); assert_eq!((v["matched"].as_u64(), v["count"].as_u64(), v["next"].as_u64()), (Some(3), Some(1), Some(2)), "{v}"); assert_eq!( v["records"][0]["links"], json!([{"url": "https://geo.example.org/wms", "kind": "wms", "name": null, "protocol": "OGC:WMS"}]) ); assert!(v["notes"][0].as_str().is_some_and(|n| n.contains("1 links")), "{v}"); assert!(v["request"].as_str().is_some_and(|r| r.contains("AnyText%20LIKE%20%27%25forest%25%27")), "{v}"); assert!(s.run("catalog.search", &json!({"url": "https://cat.example.org/csw", "text": ""})).is_err()); } /// A KMZ's ground overlays become raster layers kept in the project; a KML whose picture is a web /// address or outside its folder says so. #[test] fn kml_ground_overlays_become_rasters() { use base64::Engine; let mut red = tiny_skia::Pixmap::new(8, 8).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let doc = r#" P10.5,40.5 Redfiles/red.png4140111010 Quadfiles/red.png "#; let kmz = rustgis_data::zip::write(&[("doc.kml".into(), doc.as_bytes().to_vec()), ("files/red.png".into(), red.encode_png().unwrap())]) .unwrap(); let mut s = Session::new(Project::default()); let b64 = base64::engine::general_purpose::STANDARD.encode(&kmz); let r = s.run("layer.addDataBytes", &json!({"fileName": "site.kmz", "data": b64})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(2), "{r}"); let overlay = s.map().layer_by_name("Red").unwrap(); assert!(overlay.raster.as_ref().is_some_and(|r| r.data.is_some())); assert!(s.notices.iter().any(|n| n.text.contains("1 ground overlays without a picture or a box"))); // The project keeps the picture. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert!(t.map().layer_by_name("Red").and_then(|l| l.raster.as_ref()).is_some_and(|r| r.data.is_some())); // A KML of overlays only, whose pictures aren't readable from where it is. let dir = std::env::temp_dir().join(format!("rustgis-overlay-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let only = r#"Far../secret.png1010 Webhttps://example.com/x.png1010 Herered.png1010 Folderfolder.png1010 Gonegone.png1010 Throughred.png/x.png1010"#; std::fs::write(dir.join("o.kml"), only).unwrap(); std::fs::create_dir_all(dir.join("folder.png")).unwrap(); std::fs::write(dir.join("red.png"), red.encode_png().unwrap()).unwrap(); let mut u = Session::new(Project::default()); u.run("layer.addData", &json!({"path": dir.join("o.kml").display().to_string()})).unwrap(); assert!(u.map().layer_by_name("Here").is_some()); assert!(u.map().layer_by_name("Far").is_none() && u.map().layer_by_name("Web").is_none()); assert!(u.notices.iter().any(|n| n.text.contains("Far (its picture ../secret.png isn't in the file or beside it)"))); for name in ["Folder", "Gone", "Through"] { assert!(u.map().layer_by_name(name).is_none(), "{name}"); } // A picture beside it that can't be read says why, not that it's missing. assert!(u.notices.iter().any(|n| n.text.contains("Folder (its picture folder.png can't be read:") && n.text.contains("isn't a file"))); assert!(!u.notices.iter().any(|n| n.text.contains("folder.png isn't in the file"))); // One missing beside it (or under a file named as a folder) is said to be missing. for name in ["Gone (its picture gone.png", "Through (its picture red.png/x.png"] { assert!(u.notices.iter().any(|n| n.text.contains(&format!("{name} isn't in the file or beside it)"))), "{name}: {:?}", u.notices); } let _ = std::fs::remove_dir_all(dir); } #[test] fn pmtiles_from_network_shares_are_refused() { let mut s = Session::new(Project::default()); for p in [r"\\attacker\share\x.pmtiles", "//attacker/share/x.pmtiles", "file://attacker/share/x.pmtiles"] { assert!(s.run("map.addTiles", &json!({"url": p, "kind": "pmtiles"})).is_err(), "{p}"); } assert!(crate::render::rustgis_tiles::pmtiles::check_local(r"\\attacker\share\x.pmtiles").is_err()); assert!(crate::render::rustgis_tiles::pmtiles::check_local("/data/x.pmtiles").is_ok()); } /// A GeoPackage with more feature tables than are added at once says which were left out, in the /// result and in a notice (issue #85); `info.fileLayers` lists them all. #[test] fn geopackage_table_cap_is_reported() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-cap-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("many.gpkg"); let _ = std::fs::remove_file(&path); let c = rusqlite::Connection::open(&path).unwrap(); c.execute_batch( "CREATE TABLE gpkg_spatial_ref_sys (srs_name TEXT, srs_id INTEGER PRIMARY KEY, organization TEXT, organization_coordsys_id INTEGER, definition TEXT, description TEXT); INSERT INTO gpkg_spatial_ref_sys VALUES ('WGS 84', 4326, 'EPSG', 4326, '', ''); CREATE TABLE gpkg_contents (table_name TEXT PRIMARY KEY, data_type TEXT, identifier TEXT, description TEXT, last_change TEXT, min_x DOUBLE, min_y DOUBLE, max_x DOUBLE, max_y DOUBLE, srs_id INTEGER); CREATE TABLE gpkg_geometry_columns (table_name TEXT, column_name TEXT, geometry_type_name TEXT, srs_id INTEGER, z TINYINT, m TINYINT);", ) .unwrap(); let mut blob = b"GP\x00\x01".to_vec(); blob.extend_from_slice(&4326i32.to_le_bytes()); blob.extend(rustgis_data::wkb::encode(&rustgis_data::Geometry::Point(rustgis_data::Coord::new(2.35, 48.85)))); for i in 1..=34 { let t = format!("t_{i:02}"); c.execute_batch(&format!( "INSERT INTO gpkg_contents (table_name, data_type, srs_id) VALUES ('{t}', 'features', 4326); INSERT INTO gpkg_geometry_columns VALUES ('{t}', 'geom', 'POINT', 4326, 0, 0); CREATE TABLE {t} (fid INTEGER PRIMARY KEY AUTOINCREMENT, geom BLOB);" )) .unwrap(); c.execute(&format!("INSERT INTO {t} (geom) VALUES (?1)"), [&blob]).unwrap(); } drop(c); let p = path.display().to_string(); let mut s = Session::new(Project::default()); let listed = s.run("info.fileLayers", &json!({"path": p})).unwrap(); assert_eq!(listed["count"], 34); assert_eq!(listed["layers"][33], "t_34"); let mark = s.notice_mark(); let r = s.run("layer.addData", &json!({"path": p})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(32)); assert_eq!(r["tables"], 34); assert_eq!(r["skipped"], json!(["t_33", "t_34"])); assert_eq!(r["skippedCount"], 2); let said = s.notices_since(mark); assert!(said.iter().any(|n| n.text.contains("first 32 of 34 tables")), "{said:?}"); // A table past the cap is added by name. let one = s.run("layer.addData", &json!({"path": p, "layer": "t_34"})).unwrap(); assert!(one.get("skipped").is_none()); assert_eq!(s.map().layers.len(), 33); // The pure-Rust reader the web app lists an upload's tables with sees them all. let listed = rustgis_data::source::gpkg_mem::layers(rustgis_data::source::Shared(std::sync::Arc::new(std::fs::read(&path).unwrap()))).unwrap(); assert_eq!(listed.len(), 34); assert!(s.run("info.fileLayers", &json!({"path": dir.join("none.geojson").display().to_string()})).is_err()); let notes = dir.join("notes.rgis"); std::fs::write(¬es, "{}").unwrap(); assert!(s.run("info.fileLayers", &json!({"path": notes.display().to_string()})).is_err(), "not a data file"); std::fs::remove_dir_all(&dir).ok(); } /// Attribute-only GeoPackage tables are counted once when a whole file is added (#1634). #[test] fn geopackage_omission_counts_match_unregistered_tables_with_bounded_kinds() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-bounded-kinds-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("kinds.gpkg"); let _ = std::fs::remove_file(&path); let mut writer = world(); writer.run("share.exportLayer", &json!({"layer":"Cities","path":path,"tableName":"A"})).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("BEGIN; CREATE TABLE B AS SELECT * FROM A; INSERT INTO gpkg_contents(table_name,data_type,srs_id) VALUES ('B','attributes',4326); INSERT INTO gpkg_geometry_columns(table_name,column_name,geometry_type_name,srs_id,z,m) SELECT 'B',column_name,geometry_type_name,srs_id,z,m FROM gpkg_geometry_columns WHERE table_name='A';").unwrap(); for index in 0..1000 { conn.execute_batch(&format!("CREATE TABLE unknown_{index}(value TEXT);")).unwrap(); conn.execute( "INSERT INTO gpkg_contents(table_name,data_type) VALUES (?1,?2)", [format!("unknown_{index}"), format!("unknown {index}\n\u{1b}[31m")], ) .unwrap(); } conn.execute_batch("COMMIT;").unwrap(); drop(conn); let shared = rustgis_data::source::Shared(std::sync::Arc::new(std::fs::read(&path).unwrap())); for counts in [ rustgis_data::source::gpkg::unsupported_table_counts(&path).unwrap(), rustgis_data::source::gpkg_mem::unsupported_table_counts(shared).unwrap(), ] { assert!(counts.len() <= 5, "{} content kinds must be bounded", counts.len()); assert_eq!(counts, vec![("other".to_string(), 1000)], "registered B is added, regardless of its declared type"); } let mut session = Session::new(Project::default()); session.run("layer.addData", &json!({"path":path})).unwrap(); assert_eq!(session.map().layers.len(), 2); let notice = session.notices.iter().find(|n| n.text.contains("other table(s) not added")).unwrap(); assert!( notice.text.contains("1000 other table(s)") && notice.text.len() < 512 && !notice.text.contains(['\n', '\u{1b}']), "{}", notice.text ); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geopackage_review_malformed_contents_are_not_silently_omitted() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-malformed-catalog-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("bad.gpkg"); let _ = std::fs::remove_file(&path); let mut writer = world(); writer.run("share.exportLayer", &json!({"layer":"Cities","path":path,"tableName":"A"})).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("ALTER TABLE gpkg_contents RENAME TO original_catalog; CREATE TABLE gpkg_contents(table_name,data_type,identifier,description,last_change,min_x,min_y,max_x,max_y,srs_id); INSERT INTO gpkg_contents SELECT table_name,data_type,identifier,description,last_change,min_x,min_y,max_x,max_y,srs_id FROM original_catalog; INSERT INTO gpkg_contents(table_name,data_type) VALUES ('broken',NULL);").unwrap(); drop(conn); for invalid in [rusqlite::types::Value::Null, rusqlite::types::Value::Integer(42)] { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute("UPDATE gpkg_contents SET data_type=?1 WHERE table_name='broken'", [invalid]).unwrap(); drop(conn); for bytes in [true, false] { let mut session = Session::new(Project::default()); let result = if bytes { crate::cmd::layer::add_source( &mut session, &json!({}), crate::cmd::layer::Origin::Bytes("bad.gpkg", std::sync::Arc::new(std::fs::read(&path).unwrap())), None, ) .map(|_| ()) } else { session.run("layer.addData", &json!({"path":path})).map(|_| ()).map_err(|e| e.to_string()) }; assert!( result.is_err_and(|e| e.contains("1 invalid gpkg_contents")), "bytes={bytes}: malformed catalog must be refused before adding layers" ); assert!(session.map().layers.is_empty()); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geopackage_virtual_catalog_columns_use_the_desktop_sqlite_reader() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-virtual-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("virtual.gpkg"); world().run("share.exportLayer", &json!({"layer":"Cities","path":path,"tableName":"A"})).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("ALTER TABLE gpkg_contents RENAME TO original_catalog; CREATE TABLE gpkg_contents(table_name TEXT, data_type TEXT GENERATED ALWAYS AS ('features') VIRTUAL, identifier, description, last_change, min_x, min_y, max_x, max_y, srs_id); INSERT INTO gpkg_contents(table_name,identifier,description,last_change,min_x,min_y,max_x,max_y,srs_id) SELECT table_name,identifier,description,last_change,min_x,min_y,max_x,max_y,srs_id FROM original_catalog;").unwrap(); drop(conn); let bytes = std::sync::Arc::new(std::fs::read(&path).unwrap()); assert!( rustgis_data::source::gpkg_mem::unsupported_table_counts(rustgis_data::source::Shared(bytes.clone())) .is_err_and(|error| error.to_string().contains("virtual generated")) ); for uploaded in [false, true] { let mut session = Session::new(Project::default()); if uploaded { crate::cmd::layer::add_source(&mut session, &json!({}), crate::cmd::layer::Origin::Bytes("virtual.gpkg", bytes.clone()), None) .unwrap(); } else { session.run("layer.addData", &json!({"path":path})).unwrap(); } assert_eq!(session.map().layers.len(), 1); } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geopackage_catalog_is_required_for_empty_layer_lists() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-empty-list-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("plain.gpkg"); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("CREATE TABLE notes(value TEXT)").unwrap(); drop(conn); for valid in [false, true] { if valid { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("CREATE TABLE gpkg_contents(table_name TEXT,data_type TEXT)").unwrap(); } let mut session = Session::new(Project::default()); let result = session.run("info.fileLayers", &json!({"path":path})); let shared = rustgis_data::source::Shared(std::sync::Arc::new(std::fs::read(&path).unwrap())); let uploaded = rustgis_data::source::gpkg_mem::layers(shared); if valid { assert_eq!(result.unwrap()["count"], 0); assert!(uploaded.unwrap().is_empty()); } else { assert!(result.is_err_and(|error| error.to_string().contains("not a GeoPackage"))); assert!(uploaded.is_err_and(|error| error.to_string().contains("not a GeoPackage"))); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geopackage_attribute_tables_are_not_silently_omitted() { let dir = std::env::temp_dir().join(format!("rustgis-gpkg-attributes-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("mixed.gpkg"); let mut writer = world(); writer.run("share.exportLayer", &json!({"layer":"Cities", "path":path, "tableName":"A"})).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE B AS SELECT * FROM A; INSERT INTO gpkg_contents (table_name, data_type, srs_id) VALUES ('B', 'features', 4326); INSERT INTO gpkg_geometry_columns (table_name, column_name, geometry_type_name, srs_id, z, m) SELECT 'B', column_name, geometry_type_name, srs_id, z, m FROM gpkg_geometry_columns WHERE table_name = 'A'; CREATE TABLE notes (fid INTEGER PRIMARY KEY, note TEXT); INSERT INTO notes VALUES (1, 'Keep me'); CREATE TABLE owners (name TEXT); INSERT INTO gpkg_contents (table_name, data_type) VALUES ('notes', 'attributes'), ('owners', 'attributes'); CREATE TABLE imagery (id INTEGER PRIMARY KEY, zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB); INSERT INTO gpkg_contents (table_name, data_type) VALUES ('imagery', 'tiles'), ('unregistered', 'features');", ) .unwrap(); drop(conn); let skipped = |s: &Session| s.notices.iter().filter(|n| n.text.contains("attribute-only table")).map(|n| n.text.clone()).collect::>(); for bytes in [false, true] { let mut s = Session::new(Project::default()); if bytes { crate::cmd::layer::add_source( &mut s, &json!({}), crate::cmd::layer::Origin::Bytes("mixed.gpkg", std::sync::Arc::new(std::fs::read(&path).unwrap())), None, ) .unwrap(); } else { let answer = s.run("layer.addData", &json!({"path":path})).unwrap(); assert_eq!(answer["layers"].as_array().unwrap().len(), 2); } assert!( s.notices.iter().any(|n| n.text.contains("1 feature table(s) not added: no geometry column is registered")), "bytes={bytes}: {:?}", s.notices ); let notes = skipped(&s); assert_eq!(notes.len(), 1, "bytes={bytes}: {notes:?}"); assert!(notes[0].contains("2 attribute-only tables") && notes[0].contains("mixed.gpkg"), "{notes:?}"); assert!(s.notices.iter().any(|n| n.text.contains("1 tiles table(s) not added")), "bytes={bytes}: {:?}", s.notices); } // Selecting one feature table deliberately does not claim to add the whole file. let mut selected = Session::new(Project::default()); selected.run("layer.addData", &json!({"path":path, "layer":"A"})).unwrap(); assert!(skipped(&selected).is_empty()); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch("DELETE FROM gpkg_contents WHERE data_type <> 'features' OR table_name = 'unregistered'; DROP TABLE notes; DROP TABLE owners; DROP TABLE imagery;") .unwrap(); drop(conn); let mut clean = Session::new(Project::default()); clean.run("layer.addData", &json!({"path":path})).unwrap(); assert!(skipped(&clean).is_empty()); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( "DELETE FROM gpkg_geometry_columns; DELETE FROM gpkg_contents; CREATE TABLE notes (note TEXT); INSERT INTO gpkg_contents (table_name, data_type) VALUES ('notes', 'attributes');", ) .unwrap(); drop(conn); for missing_geometry_table in [false, true] { if missing_geometry_table { rusqlite::Connection::open(&path).unwrap().execute_batch("DROP TABLE gpkg_geometry_columns").unwrap(); } for bytes in [false, true] { let mut s = Session::new(Project::default()); let error = if bytes { crate::cmd::layer::add_source( &mut s, &json!({}), crate::cmd::layer::Origin::Bytes("mixed.gpkg", std::sync::Arc::new(std::fs::read(&path).unwrap())), None, ) .unwrap_err() } else { s.run("layer.addData", &json!({"path":path})).unwrap_err().to_string() }; assert!(error.contains("1 attribute-only table"), "bytes={bytes}: {error}"); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn ambiguous_polar_areas_are_refused_before_attributes_or_joins_are_written() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let bad = vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]; let world = vec![c(-180.0, -90.0), c(180.0, -90.0), c(180.0, 90.0), c(-180.0, 90.0)]; let mut polar = Dataset::new("Polar", GeometryType::Polygon, vec![Field::new("area", FieldType::Double)]); polar.push(Some(Geometry::Polygon(vec![vec![bad.clone()]])), vec![Value::Float(12.0)]); polar.push(Some(Geometry::Polygon(vec![vec![world, bad]])), vec![Value::Float(24.0)]); let clean_ring = vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 1.0), c(0.0, 0.0)]; polar.push(Some(Geometry::Polygon(vec![vec![clean_ring.clone()]])), vec![Value::Float(36.0)]); let mut clean = Dataset::new("Clean", GeometryType::Polygon, vec![Field::new("area", FieldType::Double)]); clean.push(Some(Geometry::Polygon(vec![vec![clean_ring]])), vec![Value::Float(0.0)]); let mut s = Session::new(Project::default()); s.map_mut().add_dataset(polar); s.map_mut().add_dataset(clean); for (command, params) in [ ("edit.calculateField", json!({"layer":"Polar", "field":"area", "expression":"$area"})), ( "edit.calculateGeometry", json!({"layer":"Polar", "field":"area", "property":"area", "unit":"m2", "scope":"all", "overwrite":true}), ), ("gp.run", json!({"tool":"calculateGeometry", "params":{"input":"Polar"}})), ("gp.run", json!({"tool":"spatialJoin", "params":{"target":"Polar", "join":"Clean", "match":"Largest overlap"}})), ("gp.run", json!({"tool":"spatialJoin", "params":{"target":"Clean", "join":"Polar", "match":"Largest overlap"}})), ] { let error = s.run(command, ¶ms).unwrap_err().to_string(); assert!(error.contains("Polar") && error.contains("2 ring(s) in 2 feature(s)") && error.contains("area is ambiguous"), "{error}"); let layer = s.map().layer_by_name("Polar").unwrap(); assert_eq!(layer.dataset.fields.len(), 1); assert_eq!(layer.dataset.value(0, 0), &Value::Float(12.0)); assert_eq!(layer.dataset.value(1, 0), &Value::Float(24.0)); assert_eq!(s.map().layers.len(), 2); } // The same operations still work for ordinary polygons. s.run("edit.calculateField", &json!({"layer":"Clean", "field":"area", "expression":"$area"})).unwrap(); assert!(s.map().layer_by_name("Clean").unwrap().dataset.value(0, 0).as_f64().is_some_and(|area| area > 0.0)); s.run("gp.run", &json!({"tool":"calculateGeometry", "params":{"input":"Clean"}})).unwrap(); s.run("gp.run", &json!({"tool":"spatialJoin", "params":{"target":"Clean", "join":"Clean", "match":"Largest overlap"}})).unwrap(); // Attribute-table calculations and joins honor a selection of clean features. let polar_id = s.map().layer_by_name("Polar").unwrap().id; s.map_mut().layer_mut(polar_id).unwrap().selection = vec![2]; s.run( "edit.calculateGeometry", &json!({"layer":"Polar", "field":"area", "property":"area", "unit":"m2", "scope":"selected", "overwrite":true}), ) .unwrap(); let layer = s.map().layer_by_name("Polar").unwrap(); assert_eq!(layer.dataset.value(0, 0), &Value::Float(12.0)); assert_eq!(layer.dataset.value(1, 0), &Value::Float(24.0)); assert!(layer.dataset.value(2, 0).as_f64().is_some_and(|area| area > 1e9)); for (target, join) in [("Clean", "Polar"), ("Polar", "Clean")] { s.run("gp.run", &json!({"tool":"spatialJoin", "params":{"target":target, "join":join, "match":"Largest overlap"}})).unwrap(); } } #[test] fn tabulate_intersection_refuses_ambiguous_input_denominators() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let mut bad = Dataset::new("Bad zone", GeometryType::Polygon, vec![Field::new("zone", FieldType::Integer)]); bad.push( Some(Geometry::Polygon(vec![vec![vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])), vec![Value::Int(1)], ); let mut clean = Dataset::new("Clean class", GeometryType::Polygon, vec![Field::new("zone", FieldType::Integer)]); clean.push( Some(Geometry::Polygon(vec![vec![vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 1.0), c(0.0, 0.0)]]])), vec![Value::Int(1)], ); let mut session = Session::new(Project::default()); session.map_mut().add_dataset(bad); session.map_mut().add_dataset(clean); for (zones, classes) in [("Bad zone", "Clean class"), ("Clean class", "Bad zone")] { let result = session.run("gp.run", &json!({"tool":"tabulateIntersection","params":{"zones":zones,"zoneFields":"zone","classes":classes}})); let shown = format!("{result:?}"); assert!( result.is_err_and(|e| e.to_string().contains("1 ring(s) in 1 feature(s)")), "ambiguous input must be refused before coverage percentages: {shown}" ); assert_eq!(session.map().layers.len(), 2); } } #[test] fn area_stored_expressions_keep_clean_rows_and_report_unavailable_values() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let clean = Geometry::Polygon(vec![vec![vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 0.0)]]]); let mut data = Dataset::new("Stored area", GeometryType::Polygon, vec![Field::new("n", FieldType::Double), Field::new("extra", FieldType::Text)]); data.push(Some(clean.clone()), vec![Value::Float(1.0), Value::Text("x".into())]); data.push(Some(clean), vec![Value::Float(2.0), Value::Text("x".into())]); let mut setup = Session::new(Project::default()); let id = setup.map_mut().add_dataset(data); setup .run("data.attributeRule", &json!({"layer":"Stored area","name":"area","kind":"calculation","field":"n","expression":"$area"})) .unwrap(); let layer = setup.map_mut().layer_mut(id).unwrap(); layer.definition_query = Some("$area > 0 AND n = 1".into()); layer.symbol_by.size = Some("$area".into()); layer.labels.default.by.text = Some("$area".into()); let clean_session = Session::new(setup.project.clone()); assert!(!clean_session.notices.iter().any(|notice| notice.text.contains("area is ambiguous"))); setup.map_mut().layer_mut(id).unwrap().dataset.features[1].geometry = Some(Geometry::Polygon(vec![vec![vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])); let mut session = Session::new(setup.project.clone()); let layer = session.map().layer(id).unwrap(); assert_eq!(layer.visible_rows(), [true, false]); assert!(!layer.styler(None).is_plain()); let labels = rustgis_map::label_by::LabelExprs::new(&layer.labels.default.by, &layer.dataset, None).unwrap(); assert!(labels.has_text() && labels.text(&layer.dataset, 0).flatten().is_some()); assert_eq!(session.notices.iter().filter(|notice| notice.text.contains("1 ring(s) in 1 feature(s)")).count(), 1); session.run("edit.deleteField", &json!({"layer":"Stored area","field":"extra"})).unwrap(); let layer = session.map().layer(id).unwrap(); assert!(layer.definition_query.is_some()); assert_eq!(layer.design.rules.len(), 1); } fn area_review_fixture() -> (Session, u32) { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let mut data = Dataset::new("Area review", GeometryType::Polygon, vec![Field::new("n", FieldType::Double)]); for (n, ring) in [ (1.0, vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 0.0)]), (2.0, vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]), ] { data.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Value::Float(n)]); } let mut session = Session::new(Project::default()); let id = session.map_mut().add_dataset(data); (session, id) } #[test] fn area_review_classification_checks_only_kept_rows() { use rustgis_map::{ color::Ramp, symbology::{Renderer, Symbol}, }; let (mut session, id) = area_review_fixture(); let layer = session.map_mut().layer_mut(id).unwrap(); layer.definition_query = Some("n = 1".into()); let rows = layer.visible_rows(); let base = Symbol::default_for(layer.geometry_type(), 0); assert!(Renderer::unique_by(&layer.dataset, &rows, "$area", Ramp::Harbour, base).is_ok()); assert!(Renderer::unique_by(&layer.dataset, &[], "$area", Ramp::Harbour, base).is_err()); } #[test] fn area_review_project_notices_group_affected_layers() { let (mut session, id) = area_review_fixture(); session.map_mut().layer_mut(id).unwrap().labels.default.by.text = Some("$area".into()); let mut copy = session.map().layer(id).unwrap().dataset.clone(); copy.name = "Second area".into(); let second = session.map_mut().add_dataset(copy); session.map_mut().layer_mut(second).unwrap().labels.default.by.text = Some("$area".into()); let opened = Session::new(session.project.clone()); let notes: Vec<_> = opened.notices.iter().filter(|n| n.text.contains("area is ambiguous")).collect(); assert_eq!(notes.len(), 1, "{notes:?}"); assert!(notes[0].text.contains("2 ring(s) in 2 feature(s)") && notes[0].text.contains("2 layer(s)")); } #[test] fn area_review_project_info_retains_label_expression() { let (mut session, id) = area_review_fixture(); session.map_mut().layer_mut(id).unwrap().labels.enabled = true; session.map_mut().layer_mut(id).unwrap().labels.default.by.text = Some("$area".into()); let info = session.run("info.project", &json!({})).unwrap(); assert_eq!(info["layers"][0]["labels"], "$area", "{info}"); } #[test] fn area_review_reports_retain_where_clauses_when_fitted() { let (mut session, _) = area_review_fixture(); session.run("report.new", &json!({"layer":"Area review","name":"Area report"})).unwrap(); session.project.reports[0].where_clause = "$area > 0".into(); let text = String::from_utf8(crate::cmd::report::report_file(&session, 0, false).unwrap()).unwrap(); session.run("report.update", &json!({"report":0,"layer":"Area review"})).unwrap(); assert_eq!(session.project.reports[0].where_clause, "$area > 0"); session.run("report.open", &json!({"text":text})).unwrap(); assert_eq!(session.project.reports[1].where_clause, "$area > 0"); for index in 0..2 { let error = crate::cmd::report::build(&session, index).unwrap_err(); assert!(error.contains("1 ring(s) in 1 feature(s)"), "{error}"); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn area_review_repair_checks_retained_classification_fields() { use rustgis_map::{ color::Ramp, symbology::{Renderer, Symbol}, }; let (mut session, id) = area_review_fixture(); let layer = session.map_mut().layer_mut(id).unwrap(); // Isolate the classification expression from the default label field's own schema check. layer.labels.default.field.clear(); let mut metadata = layer.dataset.clone(); metadata.features.clear(); layer.renderer = Renderer::unique_by(&metadata, &[], "$area + n", Ramp::Harbour, Symbol::default_for(layer.geometry_type(), 0)).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-area-repair-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let old = dir.join("old.fgb"); let new = dir.join("new.fgb"); std::fs::write(&old, rustgis_data::source::fgb::write(&layer.dataset).unwrap()).unwrap(); let handle = rustgis_data::source::open_path(&old, None).unwrap(); layer.source = Some(rustgis_map::source::SourceLayer::from_path(old.display().to_string(), None, handle)); let mut replacement = layer.dataset.clone(); replacement.fields.clear(); for feature in replacement.features.iter_mut() { feature.attrs.clear(); } std::fs::write(&new, rustgis_data::source::fgb::write(&replacement).unwrap()).unwrap(); let error = session.run("layer.repairSource", &json!({"layer":id,"path":new})).unwrap_err(); assert!(error.contains("configured expression") && error.contains("no field named 'n'"), "{error}"); std::fs::remove_dir_all(dir).ok(); } #[test] fn area_review_attribute_edits_keep_retained_display_expressions() { let (mut session, id) = area_review_fixture(); session.map_mut().layer_mut(id).unwrap().definition_query = Some("$area > 0 AND n = 1".into()); session.run("edit.setValue", &json!({"layer":id,"row":1,"field":"n","value":3})).unwrap(); assert_eq!(session.map().layer(id).unwrap().visible_rows(), [true, false]); } #[test] fn area_review_archive_constraints_refuse_ambiguous_restore() { use rustgis_map::design::{ArchiveEntry, AttributeRule, RuleKind}; let (mut session, id) = area_review_fixture(); let layer = session.map_mut().layer_mut(id).unwrap(); let mut bad = layer.dataset.features[1].clone(); let clean = layer.dataset.features[0].clone(); bad.id = clean.id; layer.dataset.features.truncate(1); layer.design.archive = Some(vec![ArchiveEntry { time: 10.0, id: clean.id, before: Some(bad), after: Some(clean.clone()) }]); layer.design.rules.push(AttributeRule { name: "area".into(), kind: RuleKind::Constraint, field: String::new(), expression: "$area > 0".into(), }); let error = session.run("data.archiveRestore", &json!({"layer":id,"time":0})).unwrap_err(); assert!(error.contains("area is ambiguous"), "{error}"); assert_eq!(session.map().layer(id).unwrap().dataset.features[0], clean); } #[test] fn area_review_streamed_display_reports_each_installed_window_once() { use std::sync::Arc; let (mut session, id) = area_review_fixture(); let layer = session.map_mut().layer_mut(id).unwrap(); let bytes = Arc::new(rustgis_data::source::fgb::write(&layer.dataset).unwrap()); let handle = rustgis_data::source::open_shared("area.fgb", bytes.clone(), None).unwrap(); layer.source = Some(rustgis_map::source::SourceLayer::from_bytes("area.fgb", bytes, None, handle)); layer.dataset.features.clear(); layer.labels.default.by.text = Some("$area".into()); let mark = session.notice_mark(); session.map_mut().sync_sources(&rustgis_geom::Extent::new(-180.0, -90.0, 180.0, 90.0), 1000.0, true); session.notice_landed_rasters(); session.notice_landed_rasters(); let notices = session.notices_since(mark); assert_eq!( notices .iter() .filter(|notice| notice.text.contains("1 ring(s) in 1 feature(s)") && notice.text.contains("decoded features")) .count(), 1, "{notices:?}" ); } #[cfg(not(target_arch = "wasm32"))] #[test] fn area_review_streamed_filters_keep_clean_features_and_report_unavailable_rows() { use std::sync::Arc; for limit in [0, 1] { let (mut session, id) = area_review_fixture(); let layer = session.map_mut().layer_mut(id).unwrap(); let bytes = Arc::new(rustgis_data::source::fgb::write(&layer.dataset).unwrap()); let handle = rustgis_data::source::open_shared("area.fgb", bytes.clone(), None).unwrap(); let mut source = rustgis_map::source::SourceLayer::from_bytes("area.fgb", bytes, None, handle); source.limit = limit; layer.source = Some(source); layer.dataset.features.clear(); layer.definition_query = Some("n = 1 AND $area > 0".into()); let mark = session.notice_mark(); let view = rustgis_geom::Extent::new(-180.0, -90.0, 180.0, 90.0); let mut pending = true; for _ in 0..1000 { pending = session.map_mut().sync_sources(&view, 1000.0, false); session.notice_landed_rasters(); if !pending { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(!pending); let layer = session.map().layer(id).unwrap(); assert_eq!(layer.visible_rows().iter().filter(|visible| **visible).count(), 1, "limit={limit}"); assert!(layer.source.as_ref().unwrap().error.is_none()); session.notice_landed_rasters(); let notes = session.notices_since(mark); assert_eq!( notes.iter().filter(|notice| notice.text.contains("1 ring(s) in 1 feature(s)") && notice.text.contains("decoded")).count(), 1, "{notes:?}" ); // Full exports still refuse an unusable area query rather than losing rows in a copy. session.map_mut().sync_sources(&view, 1000.0, true); assert!( session .map() .layer(id) .unwrap() .source .as_ref() .unwrap() .error .as_ref() .is_some_and(|error| error.contains("area is ambiguous")) ); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn area_service_refresh_retains_expressions_and_reports_unavailable_values() { use crate::tests::ogc_server::{ITEMS, Server}; let server = Server::group_fixture(); let feature = |n: i32, ring: Value| json!({"type":"Feature","properties":{"n":n},"geometry":{"type":"Polygon","coordinates":[ring]}}); let clean = json!([[0, 0], [1, 0], [1, 1], [0, 0]]); let bad = json!([[-180, 90], [180, 90], [-90, -90], [90, -80], [-180, 90]]); server.set(ITEMS, &json!({"type":"FeatureCollection","features":[feature(1,clean.clone())]}).to_string()); let mut session = Session::new(Project::default()); session.run("service.import", &json!({"url":server.root()})).unwrap(); let id = session.map().layer_by_name("Places").unwrap().id; let layer = session.map_mut().layer_mut(id).unwrap(); layer.definition_query = Some("$area > 0 AND n = 1".into()); layer.labels.default.by.text = Some("$area".into()); layer.display_filters.push(rustgis_map::effects::DisplayFilter { name: "area".into(), where_: "$area > 0".into(), min_scale: None, max_scale: None, }); server.set(ITEMS, &json!({"type":"FeatureCollection","features":[feature(1,clean),feature(2,bad)]}).to_string()); let mark = session.notice_mark(); session.run("service.refresh", &json!({"layer":id})).unwrap(); let layer = session.map().layer(id).unwrap(); assert!(layer.definition_query.is_some() && layer.labels.default.by.text.is_some()); assert_eq!(layer.display_filters.len(), 1); assert_eq!(layer.visible_rows(), [true, false]); assert_eq!(session.notices_since(mark).iter().filter(|notice| notice.text.contains("1 ring(s) in 1 feature(s)")).count(), 1); } #[test] fn area_stale_calculation_targets_do_not_block_edits() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_map::design::{AttributeRule, RuleKind}; let c = Coord::new; let mut data = Dataset::new("Stale area", GeometryType::Polygon, vec![Field::new("value", FieldType::Double)]); data.push( Some(Geometry::Polygon(vec![vec![vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])), vec![Value::Float(1.0)], ); let mut session = Session::new(Project::default()); let id = session.map_mut().add_dataset(data); session.map_mut().layer_mut(id).unwrap().design.rules.push(AttributeRule { name: "stale".into(), kind: RuleKind::Calculation, field: "gone".into(), expression: "$area".into(), }); session.run("edit.setValue", &json!({"layer":"Stale area","row":0,"field":"value","value":2})).unwrap(); session.map_mut().layer_mut(id).unwrap().design.rules.push(AttributeRule { name: "constraint".into(), kind: RuleKind::Constraint, field: String::new(), expression: "$area > 0".into(), }); assert!(session.run("edit.setValue", &json!({"layer":"Stale area","row":0,"field":"value","value":3})).is_err()); assert_eq!(session.map().layer(id).unwrap().dataset.value(0, 0), &Value::Float(2.0)); } #[test] fn area_archive_restore_cannot_invalidate_a_saved_filter() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; let c = Coord::new; let mut data = Dataset::new("Archived area", GeometryType::Polygon, vec![]); data.push(Some(Geometry::Polygon(vec![vec![vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 0.0)]]])), vec![]); let mut session = Session::new(Project::default()); let id = session.map_mut().add_dataset(data); let layer = session.map_mut().layer_mut(id).unwrap(); let clean = layer.dataset.features[0].clone(); let mut bad = clean.clone(); bad.geometry = Some(Geometry::Polygon(vec![vec![vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])); layer.design.archive = Some(vec![rustgis_map::design::ArchiveEntry { time: 10.0, id: clean.id, before: Some(bad), after: Some(clean.clone()) }]); layer.definition_query = Some("$area > 0".into()); let result = session.run("data.archiveRestore", &json!({"layer":"Archived area","time":0})); assert!(result.is_err_and(|error| error.to_string().contains("area is ambiguous"))); assert_eq!(session.map().layer(id).unwrap().dataset.features[0], clean); } #[test] fn area_rules_refuse_ambiguous_geometry_during_edits() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let clean = Geometry::Polygon(vec![vec![vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 0.0)]]]); let mut data = Dataset::new( "Area rules", GeometryType::Polygon, vec![Field::new("value", FieldType::Double), Field::new("measured", FieldType::Double)], ); for _ in 0..2 { data.push(Some(clean.clone()), vec![Value::Float(1.0), Value::Float(0.0)]); } let mut session = Session::new(Project::default()); let id = session.map_mut().add_dataset(data); session .run( "data.attributeRule", &json!({"layer":"Area rules","name":"Area calculation","kind":"calculation","field":"measured","expression":"$area"}), ) .unwrap(); session .run( "data.attributeRule", &json!({"layer":"Area rules","name":"Area constraint","kind":"constraint","expression":"value > 0 AND $area > 0"}), ) .unwrap(); session.map_mut().layer_mut(id).unwrap().dataset.features[0].geometry = Some(Geometry::Polygon(vec![vec![vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])); session.run("edit.setValue", &json!({"layer":"Area rules","row":1,"field":"value","value":2})).unwrap(); assert!(session.map().layer(id).unwrap().dataset.value(1, 1).as_f64().is_some_and(|area| area > 0.0)); let negative = session.run("edit.setValue", &json!({"layer":"Area rules","row":1,"field":"value","value":-1})); assert!(negative.is_err_and(|error| error.to_string().contains("breaks the rule")), "the constraint must still run for clean rows"); let result = session.run("edit.setValue", &json!({"layer":"Area rules","row":0,"field":"value","value":2})); assert!(result.is_err_and(|e| e.to_string().contains("Area rules") && e.to_string().contains("1 ring(s) in 1 feature(s)"))); assert_eq!(session.map().layer(id).unwrap().dataset.value(0, 0), &Value::Float(1.0)); assert_eq!(session.map().layer(id).unwrap().dataset.value(1, 0), &Value::Float(2.0)); } #[test] fn area_tabulation_omits_shapeless_features_before_checking_unused_holes() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let c = Coord::new; let clean = vec![c(0.0, 0.0), c(1.0, 0.0), c(1.0, 1.0), c(0.0, 1.0), c(0.0, 0.0)]; let mut zones = Dataset::new("Zones", GeometryType::Polygon, vec![Field::new("zone", FieldType::Integer)]); zones.push( Some(Geometry::Polygon(vec![vec![vec![c(0.0, 0.0); 3], vec![c(-180.0, 90.0), c(180.0, 90.0), c(-90.0, -90.0), c(90.0, -80.0)]]])), vec![Value::Int(1)], ); zones.push(Some(Geometry::Polygon(vec![vec![clean.clone()]])), vec![Value::Int(2)]); let mut classes = Dataset::new("Classes", GeometryType::Polygon, vec![]); classes.push(Some(Geometry::Polygon(vec![vec![clean]])), vec![]); let mut session = Session::new(Project::default()); session.map_mut().add_dataset(zones); session.map_mut().add_dataset(classes); let result = session .run("gp.run", &json!({"tool":"tabulateIntersection","params":{"zones":"Zones","zoneFields":"zone","classes":"Classes"}})) .unwrap(); assert!(result.get("table").is_some()); assert_eq!(session.notices.iter().filter(|notice| notice.text.contains("1 zone feature has no polygon and was left out")).count(), 1); } #[test] fn raster_measure_refuses_ambiguous_polar_area_instead_of_null() { let mut s = Session::new(Project::default()); let bad = json!([[-180, 90], [180, 90], [-90, -90], [90, -80]]); let error = s.run("raster.measure", &json!({"kind":"area", "points":bad})).unwrap_err().to_string(); assert!(error.contains("area is ambiguous") && error.contains("1 ring"), "{error}"); let distance = s.run("raster.measure", &json!({"kind":"distance", "points":bad})).unwrap(); assert!(distance["metres"].as_f64().is_some_and(|d| d.is_finite() && d > 0.0)); let mut closed = bad.as_array().unwrap().clone(); closed.push(closed[0].clone()); let perimeter = s.run("raster.measure", &json!({"kind":"distance","points":closed})).unwrap(); assert!(perimeter["metres"].as_f64().is_some_and(|value| value.is_finite() && value > distance["metres"].as_f64().unwrap())); let world = s.run("raster.measure", &json!({"kind":"area", "points":[[-180,-90],[180,-90],[180,90],[-180,90]]})).unwrap(); assert!(world["squareMetres"].as_f64().is_some_and(|area| area.is_finite() && area > 5e14)); } #[test] fn notices_since_survives_the_cap() { let mut s = Session::new(Project::default()); for i in 0..250 { s.notify(format!("n{i}"), false); } let mark = s.notice_mark(); assert!(s.notices_since(mark).is_empty()); s.notify("last", false); assert_eq!(s.notices_since(mark).iter().map(|n| n.text.as_str()).collect::>(), ["last"]); assert_eq!(s.notices_since(0).len(), 200); } #[test] fn service_command_keeps_missing_layer_reason_and_scrubs_every_known_address() { const WMS: &str = r#" Test WMS image/png Root roadsRoads"#; let mut s = Session::new(Project::default()); for suffix in ["?token=service-secret", "#service-secret", "?token=service-secret secret-tail", "#service-secret secret-tail"] { let url = format!("https://maps.example.com/wms{suffix}"); let missing = format!("{url} again {url}"); let mark = s.notice_mark(); let error = s.run("service.add", &json!({"url": url, "type": "wms", "layer": missing, "text": WMS})).unwrap_err(); if suffix.contains(' ') { // Invalid addresses are refused before capabilities are read. assert!(error.contains("the address has spaces in it"), "{error}"); } else { assert!(error.contains("no layer '") && error.ends_with("(service.layers lists them)"), "{error}"); assert_eq!(error.matches("maps.example.com/wms").count(), 3, "{error}"); } assert!(!error.contains("service-secret") && !error.contains("secret-tail"), "{error}"); assert_eq!(Session::redacted(&error).as_str(), error); assert_eq!(s.notices_since(mark).iter().map(|n| n.text.as_str()).collect::>(), [error.as_str()]); assert!(s.diagnostics.records().iter().any(|r| r.source == "service.add" && r.message == error)); } let report = s.diagnostics.report(rustgis_data::diagnostics::Filter::All); assert!(!report.contains("service-secret") && !report.contains("secret-tail"), "{report}"); } #[test] fn diagnostics_capture_notices_without_an_open_pane_or_project_persistence() { use rustgis_data::diagnostics::{Filter, MAX_DETAILS_BYTES, MAX_MESSAGE_BYTES, Severity}; let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.notify("diagnostics-session-only information", false); s.warn("diagnostics-session-only warning"); s.notify("diagnostics-session-only error Authorization: Bearer never-retain-this", true); let notices = s.notices_since(mark); assert_eq!(notices.len(), 3); assert!(!notices[0].error && !notices[1].error && notices[2].error); assert!(!notices[2].text.contains("never-retain-this")); let records: Vec<_> = s.diagnostics.records().into_iter().filter(|r| r.message.starts_with("diagnostics-session-only")).collect(); assert_eq!(records.iter().map(|r| r.severity).collect::>(), [Severity::Info, Severity::Warning, Severity::Error]); assert!(records.iter().all(|r| r.source == "Application" && r.timestamp_ms > 0)); assert!(!s.diagnostics.report(Filter::All).contains("never-retain-this")); // A project's saved text holds none of the history (the docs promise it). let saved = serde_json::to_string(&s.project).unwrap(); assert!(!saved.contains("diagnostics-session-only")); s.notify("界".repeat(3000), false); // The notice keeps its words to redaction's own bound (8 KiB, an ellipsis at the cut), not // the record's 2 KiB; only the history's record copy takes the message bound. let notice = &s.notices.last().unwrap().text; assert!(notice.len() <= MAX_DETAILS_BYTES && notice.ends_with('…'), "{}", notice.len()); let last = s.diagnostics.records(); let record = last.last().unwrap(); assert!(record.message.len() <= MAX_MESSAGE_BYTES && record.message.ends_with('…'), "{}", record.message.len()); s.run("project.new", &json!({})).unwrap(); let records = s.diagnostics.records(); assert!(records.iter().any(|r| r.message == "diagnostics-session-only information")); assert!(records.iter().any(|r| r.message == "diagnostics-session-only warning")); } #[test] fn diagnostics_keep_failed_command_context_without_changing_notice_rollback() { use rustgis_data::diagnostics::Severity; let mut s = Session::new(Project::default()); let before = s.project.name.clone(); let mark = s.notice_mark(); let result = s.guarded("Diagnostics rollback", true, |s| { s.project.name = "not committed".to_string(); s.notify("diagnostics-rollback information", false); s.warn("diagnostics-rollback warning"); s.notify("diagnostics-rollback error", true); Err("diagnostics-rollback failed".to_string()) }); assert!(result.is_err()); assert_eq!(s.project.name, before); assert!(s.notices_since(mark).iter().all(|n| n.error)); let records = s.diagnostics.records(); for text in ["diagnostics-rollback information", "diagnostics-rollback warning", "diagnostics-rollback error", "diagnostics-rollback failed"] { assert!(records.iter().any(|r| r.message == text), "missing {text}"); } // Registered command ids, not command parameters, identify the source. assert!(s.run("layer.rename", &json!({"layer": "diagnostics-missing-layer", "name": "never-copy-parameters"})).is_err()); assert!(s.diagnostics.records().iter().any(|r| r.source == "layer.rename" && r.severity == Severity::Error)); s.notify("diagnostics-after-command", false); assert!(s.diagnostics.records().iter().any(|r| r.message == "diagnostics-after-command" && r.source == "Application")); assert!(!s.diagnostics.report(rustgis_data::diagnostics::Filter::All).contains("never-copy-parameters")); // A panic caught inside the command must still restore Application attribution afterward. fn panic_run(_: &mut Session, _: &Value) -> crate::CmdResult { panic!("diagnostics source restore"); } let command = crate::CommandSpec { id: "diagnostics.testSourceRestore", label: "Diagnostics source restore", mutates: false, run: crate::policy::Handler::new(panic_run), effects: crate::policy::Effects::NONE, ..*s.registry.get("map.zoomIn").unwrap() }; s.registry.commands.push(command); assert!(s.run(command.id, &Value::Null).is_err()); s.notify("diagnostics-after-panic", false); assert!(s.diagnostics.records().iter().any(|r| r.message == "diagnostics-after-panic" && r.source == "Application")); } #[test] fn diagnostics_survive_project_replacement_rolled_back_within_a_command() { let mut s = Session::new(Project::default()); let before = s.project.name.clone(); let result = s.guarded("Diagnostics replacement rollback", true, |s| { s.run("project.new", &json!({"name": "temporary replacement"}))?; s.notify("diagnostics-replacement context", false); Err("diagnostics-replacement failed".to_string()) }); assert!(result.is_err()); assert_eq!(s.project.name, before); assert!(s.diagnostics.records().iter().any(|r| r.message == "diagnostics-replacement context")); assert!(s.diagnostics.records().iter().any(|r| r.message == "diagnostics-replacement failed")); } #[test] fn diagnostics_network_requests_belong_only_to_the_command_session() { use rustgis_data::diagnostics::{Capture, Network, NetworkKind}; fn failure(message: &str) { Capture::current().network_failure( "Network", message, "", Network { url: "https://session-owned.example.org/request".to_string(), method: "GET".to_string(), status: Some(503), duration_ms: Some(1), kind: NetworkKind::Http, }, ); } fn request(_: &mut Session, params: &Value) -> crate::CmdResult { let message = params.as_str().unwrap(); failure(message); match message { "b error" => Err("command refused".to_string()), "b panic" => panic!("command panicked"), _ => Ok(Value::Null), } } let mut a = Session::new(Project::default()); let mut b = Session::new(Project::default()); let command = crate::CommandSpec { id: "diagnostics.testRequest", label: "Diagnostics request", mutates: false, run: crate::policy::Handler::new(request), effects: crate::policy::Effects::NONE, ..*a.registry.get("map.zoomIn").unwrap() }; a.registry.commands.push(command); b.registry.commands.push(command); failure("unowned before command"); a.run(command.id, &json!("a command")).unwrap(); { let _a = a.diagnostics.enter(); b.run(command.id, &json!("b command")).unwrap(); assert!(b.run(command.id, &json!("b error")).is_err()); assert!(b.run(command.id, &json!("b panic")).is_err()); // Every return path, including a caught command panic, restores A's enclosing scope. failure("a restored"); } failure("unowned after command"); let messages = |s: &Session| s.diagnostics.records().into_iter().filter(|r| r.network.is_some()).map(|r| r.message).collect::>(); assert_eq!(messages(&a), ["a command", "a restored"]); assert_eq!(messages(&b), ["b command", "b error", "b panic"]); } /// The web app's `?blank&projection=EPSG:3010` (discussion #81): an empty project starts in a /// national grid, and that start is neither an edit to undo nor unsaved work. #[test] fn blank_project_starts_in_a_projected_crs() { isolate_plugins(); let mut s = Session::new(Project::default()); assert!(s.map().layers.is_empty()); let r = s.run("map.projection", &json!({"type": "EPSG:3010"})).unwrap(); assert_eq!(r["projection"], "EPSG:3010"); assert!(s.dirty() && s.can_undo()); s.clear_history(); assert!(!s.dirty() && !s.can_undo()); assert_eq!(s.map().projection.code(), "EPSG:3010"); // It opens on the country, and reports that view back. s.run("map.zoomToExtent", &json!({"extent": [10.5, 55.0, 24.5, 69.5]})).unwrap(); let e = s.run("map.extent", &Value::Null).unwrap()["extent"].clone(); let n: Vec = e.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); assert!(n[0] < 11.0 && n[2] > 24.0 && n[1] < 55.5 && n[3] > 69.0 && n[3] - n[1] < 25.0, "{n:?}"); for bad in [json!(null), json!([1, 2, 3]), json!([20, 50, 10, 60]), json!([0, 0, 200, 10]), json!(["a", 0, 1, 1])] { assert!(s.run("map.zoomToExtent", &json!({"extent": bad})).is_err(), "{bad}"); } assert!(crate::cmd::share::map_png(&mut s, Some(400.0), Some(300.0), None).unwrap().len() > 1000); } #[test] fn exporting_features_refuses_to_replace_an_existing_geopackage() { let dir = std::env::temp_dir().join(format!("rustgis-engine-gpkg-preserve-{}", std::process::id())); if dir.exists() { std::fs::remove_dir_all(&dir).unwrap(); } std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("county.gpkg"); let mut s = world(); s.run("share.exportLayer", &json!({"layer": "Cities", "path": path})).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE zoning (name TEXT); INSERT INTO zoning VALUES ('residential'); INSERT INTO gpkg_contents (table_name, data_type, identifier) VALUES ('zoning', 'attributes', 'zoning');", ) .unwrap(); drop(conn); let before = std::fs::read(&path).unwrap(); let error = s.run("share.exportLayer", &json!({"layer": "Countries", "path": path})).unwrap_err(); assert!(error.to_string().contains("Choose a new filename"), "{error}"); assert_eq!(std::fs::read(&path).unwrap(), before); std::fs::remove_dir_all(dir).unwrap(); } /// A new GeoPackage whose asked-for table name is reserved says what the table is called /// instead; one written under the name asked for says nothing of it. #[test] fn a_new_geopackage_says_when_its_table_name_was_changed() { let dir = std::env::temp_dir().join(format!("rustgis-engine-gpkg-rename-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let said = |s: &Session, mark: u64| s.notices_since(mark).iter().filter(|n| n.text.contains("instead of")).count(); let mark = s.notice_mark(); let path = dir.join("reserved.gpkg"); let out = s.run("share.exportLayer", &json!({"layer": "Cities", "path": path, "tableName": "gpkg_cities"})).unwrap(); assert_eq!(out["table"].as_str(), Some("layer_gpkg_cities"), "{out}"); assert_eq!(said(&s, mark), 1, "{:?}", s.notices_since(mark)); let both = |s: &Session, mark: u64| { s.notices_since(mark).iter().any(|n| n.text.contains("wrote the table layer_gpkg_cities instead of 'gpkg_cities'")) }; assert!(both(&s, mark), "{:?}", s.notices_since(mark)); // The default name, the layer's, is said the same way when it is changed. s.run("layer.rename", &json!({"layer": "Cities", "name": "gpkg_cities"})).unwrap(); let mark = s.notice_mark(); let path = dir.join("default.gpkg"); let out = s.run("share.exportLayer", &json!({"layer": "gpkg_cities", "path": path})).unwrap(); assert_eq!(out["table"].as_str(), Some("layer_gpkg_cities"), "{out}"); assert!(both(&s, mark), "{:?}", s.notices_since(mark)); s.run("layer.rename", &json!({"layer": "gpkg_cities", "name": "Cities"})).unwrap(); // A control character in the name asked for is dropped, and shown escaped in the notice. let mark = s.notice_mark(); let path = dir.join("control.gpkg"); let out = s.run("share.exportLayer", &json!({"layer": "Cities", "path": path, "tableName": "road\nnetwork"})).unwrap(); assert_eq!(out["table"].as_str(), Some("roadnetwork"), "{out}"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("instead of 'road\\nnetwork'")), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); let path = dir.join("plain.gpkg"); let out = s.run("share.exportLayer", &json!({"layer": "Cities", "path": path, "tableName": "towns"})).unwrap(); assert_eq!(out["table"].as_str(), Some("towns"), "{out}"); assert_eq!(said(&s, mark), 0, "{:?}", s.notices_since(mark)); drop(s); std::fs::remove_dir_all(dir).unwrap(); } /// Export Features adds a layer to an existing GeoPackage as one more table, or replaces one of /// its tables, saying which; a layer read in place from that GeoPackage can go back into it (its /// filtered copy as another table). `mode` and `tableName` are for GeoPackages only. #[test] fn exporting_features_adds_or_replaces_a_table_in_an_existing_geopackage() { let dir = std::env::temp_dir().join(format!("rustgis-engine-gpkg-add-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("county.gpkg"); let mut s = world(); s.run("share.exportLayer", &json!({"layer": "Cities", "path": path})).unwrap(); let mark = s.notice_mark(); let out = s.run("share.exportLayer", &json!({"layer": "Countries", "path": path, "mode": "addTable"})).unwrap(); assert_eq!((out["table"].as_str(), out["replaced"].as_bool()), (Some("Countries"), Some(false))); let texts: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(texts.iter().any(|t| t.ends_with("added the table Countries")), "{texts:?}"); let tables = rustgis_data::source::layers(&path).unwrap(); assert_eq!(tables, ["Cities", "Countries"]); // Taken: refused, unless replacing. let e = s.run("share.exportLayer", &json!({"layer": "Cities", "path": path, "mode": "addTable"})).unwrap_err(); assert!(e.to_string().contains("already has a table Cities"), "{e}"); let cities = s.map().layer_by_name("Cities").map(|l| l.dataset.features.len()).unwrap(); let mark = s.notice_mark(); let out = s.run("share.exportLayer", &json!({"layer": "Countries", "path": path, "mode": "replaceTable", "tableName": "Cities"})).unwrap(); assert_eq!(out["replaced"].as_bool(), Some(true)); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("replaced its table Cities, dropping"))); let countries = s.map().layer_by_name("Countries").map(|l| l.dataset.features.len()).unwrap(); let count = |t: &str| rustgis_data::source::open_path(&path, Some(t)).unwrap().info().count; assert_eq!(count("Cities"), Some(countries as u64)); assert_ne!(cities, countries); // Read in place from the GeoPackage, then exported back into it. s.run("layer.addData", &json!({"path": path.display().to_string(), "layer": "Countries", "stream": true, "name": "InPlace"})).unwrap(); s.run("share.exportLayer", &json!({"layer": "InPlace", "path": path, "mode": "addTable", "tableName": "copy"})).unwrap(); assert_eq!(count("copy"), Some(countries as u64)); // Replacing the table a layer reads in place makes it read the new table, and says so. let kind = |s: &Session| s.map().layer_by_name("InPlace").and_then(|l| l.source.as_ref()).and_then(|src| src.info()).map(|i| i.geometry_type); s.map_mut().layers.iter_mut().for_each(|l| { l.sync_source(&rustgis_geom::Extent::new(-20.0, -20.0, 20.0, 20.0), true); }); assert_eq!(kind(&s), Some(rustgis_data::GeometryType::Polygon)); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Cities", "path": path, "mode": "replaceTable", "tableName": "Countries"})).unwrap(); assert_eq!(kind(&s), Some(rustgis_data::GeometryType::Point), "the new table's"); let read = s.map().layer_by_name("InPlace").map(|l| l.load_all().map(|ds| ds.features.len())); assert_eq!(read, Some(Ok(cities)), "its features are the new table's"); assert!(s.notices_since(mark).iter().any(|n| n.text.ends_with("1 layer reads Countries in place, now the new table"))); // A layer loaded for editing from a replaced table keeps its rows and edits, and is named. s.run("gpkg.addLayer", &json!({"path": path, "table": "Cities", "name": "Editing"})).unwrap(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Countries", "path": path, "mode": "replaceTable", "tableName": "Cities"})).unwrap(); assert!( s.notices_since(mark) .iter() .any(|n| n.text.contains("1 layer loaded for editing from Cities still shows") && n.text.ends_with(": Editing")), "{:?}", s.notices_since(mark) ); // A standalone table goes in as an attribute table. s.run("layer.addDataText", &json!({"fileName": "owners.csv", "text": "name,n\nShelby,1\n"})).unwrap(); let table = s.project.tables.last().map(|t| t.id).unwrap(); let out = s.run("share.exportTable", &json!({"table": table, "path": path, "mode": "addTable"})).unwrap(); assert_eq!(out["table"].as_str(), Some("owners")); let c = rusqlite::Connection::open(&path).unwrap(); assert_eq!( c.query_row("SELECT data_type FROM gpkg_contents WHERE table_name = 'owners'", [], |r| r.get::<_, String>(0)).unwrap(), "attributes" ); drop(c); // Other formats take neither; a mode not known is refused. let geojson = dir.join("x.geojson"); for p in [json!({"mode": "addTable"}), json!({"tableName": "x"})] { let mut p = p; p["path"] = json!(geojson); assert!(s.run("share.exportLayer", &p).unwrap_err().to_string().contains("GeoPackage"), "{p}"); } assert!(s.run("share.exportLayer", &json!({"path": path, "mode": "append"})).is_err()); // Not strings: refused, not taken for the default. for p in [json!({"mode": 1}), json!({"mode": "addTable", "tableName": ["x"]})] { let mut p = p; p["path"] = json!(path); assert!(s.run("share.exportLayer", &p).unwrap_err().to_string().contains("must be a string"), "{p}"); } assert!(!geojson.exists()); // The layer read in place holds the file open, which Windows won't remove. drop(s); std::fs::remove_dir_all(dir).unwrap(); } /// A layer whose Z values were dropped on import says so when added, read whole or in place, and /// again when exported or converted; a 2D layer's export says nothing of it. #[test] fn dropped_z_values_are_said_on_import_and_export() { let dir = std::env::temp_dir().join(format!("rustgis-engine-zm-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("wells.geojson"); std::fs::write( &src, r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[1,2,30]}}]}"#, ) .unwrap(); isolate_plugins(); let mut s = Session::new(Project::default()); for stream in [false, true] { let mark = s.notice_mark(); let id = s.run("layer.addData", &json!({"path": src, "stream": stream})).unwrap()["layer"].as_u64().unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("Z or M values")), "stream {stream}: {:?}", s.notices_since(mark)); let out = dir.join(format!("out-{stream}.geojson")); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": out})).unwrap(); let said = |s: &Session, mark| s.notices_since(mark).iter().any(|n| n.text.contains("its geometry has no Z or M values")); assert!(said(&s, mark), "stream {stream}: {:?}", s.notices_since(mark)); assert!(std::fs::read_to_string(&out).unwrap().contains("[1.0,2.0]")); let mark = s.notice_mark(); s.run("data.convert", &json!({"layer": id, "format": "wkt"})).unwrap(); assert!(said(&s, mark), "stream {stream}"); } let mut s = world(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Cities", "path": dir.join("cities.geojson")})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("Z or M")), "{:?}", s.notices_since(mark)); std::fs::remove_dir_all(dir).unwrap(); } /// A standalone table can hold geometry whose Z values were dropped when it was read (a plugin's /// output, a project file): exporting it says so, to every format, once the file is written, as /// `share.exportLayer` does. A table read without Z, and a write that fails, say nothing of it. #[test] fn exporting_a_table_says_its_dropped_z_values() { let dir = std::env::temp_dir().join(format!("rustgis-engine-table-zm-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("wells.geojson"); std::fs::write( &src, r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"depth":30},"geometry":{"type":"Point","coordinates":[1,2,30]}}]}"#, ) .unwrap(); isolate_plugins(); let mut s = Session::new(Project::default()); let (wells, report) = rustgis_data::io::read_path(&src).unwrap(); assert!(wells.zm_dropped && report.messages.iter().any(|m| m.contains("Z or M")), "premise: the reader dropped Z"); s.project.add_table(wells); let said = |s: &Session, mark| -> Vec { s.notices_since(mark).iter().filter(|n| n.text.contains("Z or M")).map(|n| n.text.clone()).collect() }; for ext in ["geojson", "csv", "shp", "zip"] { let out = dir.join(format!("wells.{ext}")).display().to_string(); let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table": "wells", "path": out})).unwrap(); assert_eq!(said(&s, mark), [format!("{out}: its geometry has no Z or M values, which were dropped when wells was read")], ".{ext}"); } // The file holds x and y only. assert!(std::fs::read_to_string(dir.join("wells.geojson")).unwrap().contains("[1.0,2.0]")); // A project file keeps what the table's read lost, as it does a layer's (the Z or M values, // the coordinate system, geometry lost to it), so a table opened from one says it too. let crs = rustgis_data::raster::Crs::parse("EPSG:3857").unwrap(); { let wells = s.project.tables.last_mut().unwrap(); wells.crs = Some(crs); wells.crs_incomplete = true; } let project = dir.join("wells.rgis"); s.run("project.saveAs", &json!({"path": project.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": project.display().to_string()})).unwrap(); let opened = &t.project.tables.get(t.table_index(&json!({"table": "wells"})).unwrap()).unwrap().dataset; assert!(opened.zm_dropped && opened.crs_incomplete && opened.crs == Some(crs), "{opened:?}"); let out = dir.join("opened.geojson").display().to_string(); let mark = t.notice_mark(); t.run("share.exportTable", &json!({"table": "wells", "path": out})).unwrap(); assert_eq!(said(&t, mark), [format!("{out}: its geometry has no Z or M values, which were dropped when wells was read")]); // A write that fails says nothing of what the file would have lacked. let missing = dir.join("missing").join("wells.geojson").display().to_string(); let mark = s.notice_mark(); assert!(s.run("share.exportTable", &json!({"table": "wells", "path": missing})).is_err()); assert!(said(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); // A table read without Z: no notice. std::fs::write( dir.join("flat.geojson"), r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"depth":30},"geometry":{"type":"Point","coordinates":[1,2]}}]}"#, ) .unwrap(); let (flat, _) = rustgis_data::io::read_path(&dir.join("flat.geojson")).unwrap(); assert!(!flat.zm_dropped, "premise"); s.project.add_table(flat); for ext in ["geojson", "shp"] { let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table": "flat", "path": dir.join(format!("flat.{ext}"))})).unwrap(); assert!(said(&s, mark).is_empty(), ".{ext}: {:?}", s.notices_since(mark)); } std::fs::remove_dir_all(dir).unwrap(); } /// What an import says about positions, an export says again: a NAD 27 Shapefile whose `.prj` /// has no shift to WGS 84 (read whole or in place, and after the project is saved and opened) /// exported to GeoJSON, but not written back to a Shapefile in NAD 27; and a Shapefile without a /// `.prj` or a CSV whose coordinates aren't longitude/latitude, exported, converted or shared. /// Sample layers say neither. #[test] fn exports_say_what_import_said_about_positions() { const NAD27: &str = r#"PROJCS["NAD_1927_UTM_Zone_16N",GEOGCS["GCS_North_American_1927",DATUM["D_North_American_1927",SPHEROID["Clarke_1866",6378206.4,294.9786982]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-87.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]]"#; let dir = std::env::temp_dir().join(format!("rustgis-engine-positions-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); isolate_plugins(); let pt = dir.join("pt.geojson"); std::fs::write( &pt, r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":1},"geometry":{"type":"Point","coordinates":[-89.9,35.1]}}]}"#, ) .unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": pt, "name": "pt"})).unwrap(); let (nad27, bare) = (dir.join("nad27.shp"), dir.join("bare.shp")); s.run("share.exportLayer", &json!({"layer": "pt", "path": nad27, "crs": "EPSG:32616"})).unwrap(); std::fs::write(nad27.with_extension("prj"), NAD27).unwrap(); s.run("share.exportLayer", &json!({"layer": "pt", "path": bare, "crs": "EPSG:32616"})).unwrap(); std::fs::remove_file(bare.with_extension("prj")).unwrap(); let csv = dir.join("utm.csv"); std::fs::write(&csv, "id,x,y\n1,500000,3800000\n").unwrap(); let said = |s: &Session, mark, what: &str| s.notices_since(mark).iter().any(|n| n.text.contains(what)); const APPROXIMATE: &str = "are approximate, possibly a few hundred metres off"; const NOT_LONLAT: &str = "aren't longitude/latitude, so they're out of place"; for stream in [false, true] { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); let id = s.run("layer.addData", &json!({"path": nad27, "stream": stream})).unwrap()["layer"].as_u64().unwrap(); assert!(said(&s, mark, "has no conversion to WGS 84"), "stream {stream}: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": dir.join(format!("nad27-{stream}.geojson"))})).unwrap(); assert!(said(&s, mark, APPROXIMATE), "stream {stream}: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": dir.join(format!("back-{stream}.shp"))})).unwrap(); assert!(!said(&s, mark, APPROXIMATE), "stream {stream}: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); let id = s.run("layer.addData", &json!({"path": bare, "stream": stream})).unwrap()["layer"].as_u64().unwrap(); assert!(said(&s, mark, "not longitude/latitude"), "stream {stream}"); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": dir.join(format!("bare-{stream}.geojson"))})).unwrap(); assert!(said(&s, mark, NOT_LONLAT), "stream {stream}: {:?}", s.notices_since(mark)); } let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": nad27, "name": "nad27"})).unwrap(); s.run("layer.addData", &json!({"path": csv, "name": "utm"})).unwrap(); let saved = s.project.saved_text(None).unwrap().0; let mut s = Session::new(Project::from_json(&saved).unwrap()); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "nad27", "path": dir.join("reopened.geojson")})).unwrap(); assert!(said(&s, mark, APPROXIMATE), "{:?}", s.notices_since(mark)); for (cmd, params) in [ ("share.exportLayer", json!({"layer": "utm", "path": dir.join("utm.geojson")})), ("data.convert", json!({"layer": "utm", "format": "geojson"})), ] { let mark = s.notice_mark(); s.run(cmd, ¶ms).unwrap(); assert!(said(&s, mark, NOT_LONLAT), "{cmd}: {:?}", s.notices_since(mark)); } for (layer, expected) in [("nad27", APPROXIMATE), ("utm", NOT_LONLAT)] { let mut dataset = s.map().layer_by_name(layer).unwrap().dataset.clone(); dataset.name = format!("table-{layer}"); s.project.add_table(dataset); let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table":format!("table-{layer}"),"path":dir.join(format!("table-{layer}.geojson"))})).unwrap(); assert!(said(&s, mark, expected), "{layer}: {:?}", s.notices_since(mark)); } let mark = s.notice_mark(); s.run("share.webMap", &json!({"path": dir.join("map.html")})).unwrap(); let notes: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(notes.iter().filter(|n| n.contains(APPROXIMATE) || n.contains(NOT_LONLAT)).count(), 2, "{notes:?}"); let mut s = world(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Cities", "path": dir.join("cities.geojson")})).unwrap(); assert!(!said(&s, mark, APPROXIMATE) && !said(&s, mark, NOT_LONLAT), "{:?}", s.notices_since(mark)); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn rounded_raster_values_are_said_on_import_and_export() { let dir = std::env::temp_dir().join(format!("rustgis-engine-rounded-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let grid = |name: &str, values: &str| { let path = dir.join(name); std::fs::write(&path, format!("ncols 2\nnrows 2\nxllcorner 10\nyllcorner 40\ncellsize 1\n{values}")).unwrap(); path }; let mut s = Session::new(Project::default()); for (path, rounded) in [(grid("ids.asc", "16777217 100000001\n1234567.891 7"), true), (grid("clean.asc", "16777216 12.3\n123.4567 7"), false)] { let mark = s.notice_mark(); let id = s.run("layer.addData", &json!({"path": path})).unwrap()["layer"].as_u64().unwrap(); let said = |s: &Session, mark, text: &str| s.notices_since(mark).iter().any(|n| n.text.contains(text)); assert_eq!(said(&s, mark, "3 values were rounded as the file was read"), rounded, "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); let out = dir.join(format!("out-{id}.tif")); s.run("share.exportRaster", &json!({"layer": id, "path": out})).unwrap(); assert_eq!(said(&s, mark, "3 values in it were rounded to 32-bit floats"), rounded, "{:?}", s.notices_since(mark)); // The exported file holds the values as they were held: 16777217 is 16777216. assert_eq!(rustgis_data::raster::read_path(&out).unwrap().value(0, 0, 0), Some(16_777_216.0)); // A fresh candidate discloses the new file's losses, not the previous pixels' report. let values = if rounded { "9 2\n3 4" } else { "16777217 100000001\n1234567.891 7" }; std::fs::write(&path, format!("ncols 2\nnrows 2\nxllcorner 10\nyllcorner 40\ncellsize 1\n{values}")).unwrap(); let mark = s.notice_mark(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); assert_eq!(said(&s, mark, "3 values were rounded as the file was read"), !rounded, "{:?}", s.notices_since(mark)); let pixel = s.map().layer(id as u32).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0); assert_eq!(pixel, Some(if rounded { 9.0 } else { 16_777_216.0 })); } std::fs::remove_dir_all(dir).unwrap(); } /// Downloads and files made by mosaic/warp report the rounded values they actually write. #[test] fn raster_download_mosaic_and_warp_report_export_losses() { let dir = std::env::temp_dir().join(format!("rustgis-raster-export-notes-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (name, values, rounded) in [("rounded", "16777217 100000001\n1234567.891 7", true), ("clean", "1 2\n3 4", false)] { let mut s = Session::new(Project::default()); let path = dir.join(format!("{name}.asc")); std::fs::write(&path, format!("ncols 2\nnrows 2\nxllcorner 10\nyllcorner 40\ncellsize 1\n{values}")).unwrap(); let id = s.run("layer.addData", &json!({"path": path, "name": name})).unwrap()["layer"].as_u64().unwrap(); for cog in [false, true] { let mark = s.notice_mark(); let (_, bytes) = crate::cmd::raster::geotiff_bytes(&mut s, &json!({"layer": id, "cog": cog})).unwrap(); assert_eq!(rustgis_data::raster::read_bytes("download.tif", &bytes).unwrap().width, 2); let notes = s.notices_since(mark); assert_eq!( notes.iter().filter(|n| n.text.contains("3 values in it were rounded to 32-bit floats")).count(), usize::from(rounded), "download, cog={cog}: {notes:?}" ); } let points = [[0, 0, 10, 42], [2, 0, 12, 42], [0, 2, 10, 40]]; for (command, mut params) in [ ("raster.mosaic", json!({"inputs": [name, name], "name": "mosaic"})), ("raster.georeference", json!({"layer": name, "transform": "affine", "points": points, "name": "warped"})), ] { let output = dir.join(format!("{name}-{command}.tif")); params["path"] = json!(output); let mark = s.notice_mark(); s.run(command, ¶ms).unwrap(); assert!(rustgis_data::raster::read_path(&output).is_ok()); let notes = s.notices_since(mark); assert_eq!( notes.iter().filter(|n| n.text.contains("some values in it may have been rounded to 32-bit floats")).count(), usize::from(rounded), "{command}: {notes:?}" ); assert!(!notes.iter().any(|n| n.text.contains("3 values in it")), "{command}: {notes:?}"); } } std::fs::remove_dir_all(dir).unwrap(); } /// Metadata limits are reported from downloads, mosaics and warped files, with the files checked. #[test] fn raster_export_paths_report_band_class_and_credit_changes() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let dir = std::env::temp_dir().join(format!("rustgis-raster-export-metadata-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: 10.0, origin_y: 42.0, pixel_width: 1.0, pixel_height: -1.0 }; let mut r = Raster::new("Metadata", 2, 2, vec![vec![1.0; 4]; 5], gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "Float32").unwrap(); r.bands[0].name = "red\u{1}name".into(); r.bands[0].classes = vec!["zero".into(), "one\u{2}name".into()]; r.bands[1].classes = vec!["zero".into(), "green class".into()]; r.copyright = Some("© Owner".into()); let mut other = r.clone(); other.name = "Other".into(); other.copyright = Some("Another owner".into()); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "Metadata", rustgis_map::RasterLayer::from_bytes(r, "metadata.tif", bytes)).unwrap(); let bytes = geotiff::write(&other, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "Other", rustgis_map::RasterLayer::from_bytes(other, "other.tif", bytes)).unwrap(); let check = |s: &Session, mark, bytes: &[u8]| { let notes = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect::>().join("\n"); for text in [ "3 of its 5 bands written", "class names of band(s) 2 aren't written", "Copyright tag holds ASCII", "2 band or class name(s) held control characters", ] { assert!(notes.contains(text), "missing {text}: {notes}"); } let written = rustgis_data::raster::read_bytes("written.tif", bytes).unwrap(); assert_eq!(written.band_count(), 3); assert_eq!(written.bands[0].name, "red name"); assert_eq!(written.bands[0].classes, ["zero", "one name"]); assert_eq!(written.copyright.as_deref(), Some("(c) Owner")); }; for cog in [false, true] { let mark = s.notice_mark(); let (_, bytes) = crate::cmd::raster::geotiff_bytes(&mut s, &json!({"layer": "Metadata", "cog": cog})).unwrap(); check(&s, mark, &bytes); } let points = [[0, 0, 10, 42], [2, 0, 12, 42], [0, 2, 10, 40]]; for (cmd, mut params) in [ ("raster.mosaic", json!({"inputs": ["Metadata", "Metadata"], "name": "mosaic"})), ( "raster.georeference", json!({"layer": "Metadata", "transform": "affine", "points": points, "resampling": "nearest", "name": "warped"}), ), ] { let path = dir.join(format!("{cmd}.tif")); params["path"] = json!(path); let mark = s.notice_mark(); s.run(cmd, ¶ms).unwrap(); check(&s, mark, &std::fs::read(path).unwrap()); } for (second, changed) in [("Metadata", false), ("Other", true)] { let mark = s.notice_mark(); let out = s.run("raster.mosaic", &json!({"inputs": ["Metadata", second], "name": "rights"})).unwrap(); assert_eq!(out_raster(&s, &out).copyright.as_deref(), Some("© Owner")); assert_eq!(s.notices_since(mark).iter().any(|n| n.text.contains("copyright differs")), changed, "{:?}", s.notices_since(mark)); } std::fs::remove_dir_all(dir).unwrap(); } /// Class labels describe source values; interpolating or averaging those values drops the labels /// with a notice, while nearest, mode and palette-safe sampling retain them. #[test] fn raster_resampling_class_names_follow_the_values_and_report_losses() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let dir = std::env::temp_dir().join(format!("rustgis-resampled-classes-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); let labels: Vec = ["unknown", "one", "two", "water", "snow", "forest"].map(str::to_owned).to_vec(); let gt = GeoTransform { origin_x: 10.0, origin_y: 42.0, pixel_width: 1.0, pixel_height: -1.0 }; for (name, color, labelled, data_type) in [ ("Classes", ColorHint::Gray, true, "Float32"), ("Palette", ColorHint::Palette((0..6).map(|i| [i * 30, 0, 0, 255]).collect()), true, "UInt8"), ("Clean", ColorHint::Gray, false, "Float32"), ] { let mut r = Raster::new(name, 2, 2, vec![vec![3.0, 5.0, 5.0, 3.0]], gt, Crs::LonLat, None, color, data_type).unwrap(); if labelled { r.bands[0].classes = labels.clone(); } let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); } let points = [[0, 0, 10, 42], [2, 0, 12, 42], [0, 2, 10, 40]]; let params = |cmd: &str, layer: &str| { if cmd == "raster.georeference" { json!({"layer":layer, "transform":"affine", "points":points, "cellSize":0.5}) } else { json!({"layer":layer, "crs":4326, "extent":[10,40,12,42], "cellSize":2}) } }; let lost = |n: &&crate::Notice| n.text.contains("class names of") && n.text.contains("not kept under"); for (cmd, requested, method, keep) in [ ("raster.georeference", None, "bilinear", false), ("raster.georeference", Some("nearest"), "nearest", true), ("raster.georeference", Some("cubic"), "cubic", false), ("raster.project", Some("nearest"), "nearest", true), ("raster.project", Some("bilinear"), "bilinear", false), ("raster.project", Some("cubic"), "cubic", false), ("raster.project", Some("average"), "average", false), ("raster.project", Some("mode"), "mode", true), ] { for file in [false, true] { let mut p = params(cmd, "Classes"); p["name"] = json!(format!("{cmd}-{method}-{file}")); if let Some(method) = requested { p["resampling"] = json!(method); } let path = dir.join(format!("{cmd}-{method}.tif")); if file { p["path"] = json!(path); } let mark = s.notice_mark(); let answer = s.run(cmd, &p).unwrap(); let out = out_raster(&s, &answer); if keep { assert_eq!(out.bands[0].classes, labels, "{cmd}/{method}"); } else { assert!(out.bands[0].classes.is_empty(), "{cmd}/{method}: labels no longer describe the values"); assert!(out.bands[0].data.iter().any(|v| v.is_finite() && *v != 3.0 && *v != 5.0), "{cmd}/{method}: values really mixed"); } let notes: Vec<_> = s.notices_since(mark).iter().filter(lost).collect(); assert_eq!(notes.len(), usize::from(!keep), "{cmd}/{method}: {:?}", s.notices_since(mark)); if !keep { assert!(notes[0].text.contains("1 band(s) (1)") && notes[0].text.contains(method), "{:?}", notes[0]); } if file { let written = rustgis_data::raster::read_bytes("written.tif", &std::fs::read(path).unwrap()).unwrap(); assert_eq!(written.bands[0].classes, out.bands[0].classes); } } } // Palette requests use value-preserving methods, and report the effective method. for (cmd, requested, effective) in [("raster.georeference", "bilinear", "nearest"), ("raster.project", "bilinear", "nearest"), ("raster.project", "average", "mode")] { let mut p = params(cmd, "Palette"); p["name"] = json!(format!("palette-{cmd}-{requested}")); p["resampling"] = json!(requested); let mark = s.notice_mark(); let answer = s.run(cmd, &p).unwrap(); assert_eq!(answer["resampling"], effective); assert_eq!(out_raster(&s, &answer).bands[0].classes, labels); assert!(!s.notices_since(mark).iter().any(|n| lost(&n))); } for cmd in ["raster.georeference", "raster.project"] { let mark = s.notice_mark(); s.run(cmd, ¶ms(cmd, "Clean")).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| lost(&n))); } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn raster_export_review_tool_notice_preserves_path_and_band_guidance() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let dir = std::env::temp_dir().join(format!("rustgis-mosaic-band-guidance-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let filename = if cfg!(target_os = "windows") { "mosaic.tif" } else { "give \"band\" for another.tif" }; let path = dir.join(filename); let gt = GeoTransform { origin_x: 0.0, origin_y: 2.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new( "Multi", 2, 2, vec![vec![1.0, 2.0, 3.0, 4.0], vec![5.0, 6.0, 7.0, 8.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32", ) .unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); let mut s = Session::new(Project::default()); crate::cmd::raster::add_raster(&mut s, "Multi", rustgis_map::RasterLayer::from_bytes(r, "multi.tif", bytes)).unwrap(); let mark = s.notice_mark(); s.run("raster.mosaic", &json!({"inputs":["Multi","Multi"],"name":"Mosaic","path":path})).unwrap(); assert!(path.is_file()); let notes = s.notices_since(mark); let note = ¬es.iter().find(|n| n.text.starts_with("Exported ")).unwrap().text; assert!(note.starts_with(&format!("Exported {}", path.display())), "{note}"); assert!(note.contains("1 of its 2 bands written"), "{note}"); let instruction = "export the resulting layer with share.exportRaster and give \"band\" for another"; assert_eq!(note.matches(instruction).count(), 1, "{note}"); std::fs::remove_dir_all(dir).unwrap(); } /// A raster clipped, resampled, mosaicked, warped or pixel-edited from one with values rounded when it /// was read keeps a mark /// of them, with no count (it holds an unknown share), and its export says so; one made from a /// clean raster says nothing of rounding (#1614). #[test] fn rasters_made_from_rounded_ones_keep_the_rounded_note() { let dir = std::env::temp_dir().join(format!("rustgis-engine-rounded-part-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); const SOME: &str = "some values in it may have been rounded to 32-bit floats"; let raster = |s: &Session, name: &str| s.map().layer_by_name(name).and_then(|l| l.raster.as_ref()?.data.clone()).unwrap(); let mut s = Session::new(Project::default()); for (file, values, rounded) in [("ids.asc", "16777217 100000001\n1234567.891 7", true), ("clean.asc", "16777216 12.3\n123.4567 7", false)] { let path = dir.join(file); std::fs::write(&path, format!("ncols 2\nnrows 2\nxllcorner 10\nyllcorner 40\ncellsize 1\n{values}")).unwrap(); let id = s.run("layer.addData", &json!({"path": path})).unwrap()["layer"].as_u64().unwrap(); let name = s.map().layer(id as u32).unwrap().name.clone(); let clip = format!("{name}_part"); s.run("raster.process", &json!({"layer": name, "operation": "clip", "extent": [10.2, 41.2, 10.8, 41.8], "name": clip})).unwrap(); let clipped = raster(&s, &clip); assert_eq!((clipped.width, clipped.height, clipped.rounded.is_empty()), (1, 1, true), "{file}"); assert_eq!(clipped.rounded_some, if rounded { vec![true] } else { vec![] }, "{file}"); let (half, joined) = (format!("{name}_half"), format!("{name}_joined")); s.run("raster.process", &json!({"layer": name, "operation": "resample", "factor": 2, "name": half})).unwrap(); s.run("raster.mosaic", &json!({"inputs": [clip, name], "name": joined})).unwrap(); let warped = format!("{name}_warped"); let points = [[0, 0, 10, 42], [2, 0, 12, 42], [0, 2, 10, 40]]; let fit = json!({"layer": name, "transform": "affine", "crs": 4326, "pointsCrs": 4326, "points": points, "name": warped}); s.run("raster.georeference", &fit).unwrap(); let projected = format!("{name}_projected"); let projected_file = format!("{name}_projected_file"); for (output, file) in [(&projected, false), (&projected_file, true)] { let mut p = json!({"layer":name, "crs":4326, "extent":[10,40,12,42], "cellSize":0.5, "name":output}); if file { p["path"] = json!(dir.join(format!("{output}.tif"))); } let mark = s.notice_mark(); s.run("raster.project", &p).unwrap(); assert_eq!( s.notices_since(mark).iter().any(|n| n.text.contains(SOME)), file && rounded, "{output}: {:?}", s.notices_since(mark) ); } s.run( "raster.editPixels", &json!({"layer": name, "polygon": [[10.1, 40.1], [10.9, 40.1], [10.9, 40.9], [10.1, 40.9]], "value": 1}), ) .unwrap(); for layer in [clip.as_str(), half.as_str(), joined.as_str(), warped.as_str(), projected.as_str(), projected_file.as_str(), name.as_str()] { let mark = s.notice_mark(); s.run("share.exportRaster", &json!({"layer": layer, "path": dir.join(format!("{layer}.tif"))})).unwrap(); let notes = s.notices_since(mark); assert_eq!(notes.iter().any(|n| n.text.contains(SOME)), rounded, "{layer}: {notes:?}"); // No count is given: the whole file's would be wrong for a part of it. assert!(!notes.iter().any(|n| n.text.contains("3 values in it")), "{layer}: {notes:?}"); } } std::fs::remove_dir_all(dir).unwrap(); } /// A raster derived from a multiband grey one (resampled, clipped, pixel-edited) keeps every band /// in the project: saved and reopened, its bands are as they were. #[test] fn derived_multiband_rasters_keep_every_band_in_the_project() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let dir = std::env::temp_dir().join(format!("rustgis-engine-derived-bands-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let gt = GeoTransform { origin_x: 10.0, origin_y: 42.0, pixel_width: 0.5, pixel_height: -0.5 }; let bands = vec![(0..16).map(|i| i as f32).collect(), (0..16).map(|i| 100.0 + i as f32).collect()]; let r = Raster::new("two", 4, 4, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let path = dir.join("two.tif"); std::fs::write(&path, geotiff::write_all(&r).unwrap()).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path})).unwrap(); s.run("raster.process", &json!({"layer": "two", "operation": "resample", "factor": 2, "name": "half"})).unwrap(); s.run("raster.process", &json!({"layer": "two", "operation": "clip", "extent": [10.0, 40.0, 11.0, 41.0], "name": "part"})).unwrap(); s.run("raster.editPixels", &json!({"layer": "two", "polygon": [[10.1, 41.6], [10.4, 41.6], [10.4, 41.9], [10.1, 41.9]], "value": 1})) .unwrap(); let bands = |s: &Session, name: &str| { let r = s.map().layer_by_name(name).and_then(|l| l.raster.as_ref()?.data.clone()).unwrap(); r.bands.iter().map(|b| b.data.iter().map(|v| v.to_bits()).collect::>()).collect::>() }; let before: Vec<_> = ["half", "part", "two"].iter().map(|name| bands(&s, name)).collect(); assert!(before.iter().all(|b| b.len() == 2)); // Each derived band comes from its own source band: the first's values are under 16, the // second's 100 or more (NaN where a cell has none). for (name, bands) in ["half", "part"].iter().zip(&before) { let values = |b: usize| bands[b].iter().map(|v| f32::from_bits(*v)).filter(|v| v.is_finite()).collect::>(); assert!(!values(1).is_empty() && values(0).iter().all(|v| *v < 16.0) && values(1).iter().all(|v| *v >= 100.0), "{name}"); // Clipping and resampling are linear, so each derived cell keeps band 2 = band 1 + 100. let (first, second) = (&bands[0], &bands[1]); assert!( first.iter().zip(second).all(|(a, b)| { let (a, b) = (f32::from_bits(*a), f32::from_bits(*b)); (a.is_nan() && b.is_nan()) || (b - a - 100.0).abs() < 1e-3 }), "{name}" ); } // The edit set its pixel (the top-left one) in both bands and left the next one alone. let pixel = |band: usize, i: usize| before[2][band][i]; assert_eq!([pixel(0, 0), pixel(1, 0), pixel(1, 1)], [1.0f32, 1.0, 101.0].map(f32::to_bits)); let proj = dir.join("bands.rgis"); s.run("project.saveAs", &json!({"path": proj})).unwrap(); s.run("project.open", &json!({"path": proj})).unwrap(); for (name, before) in ["half", "part", "two"].iter().zip(before) { assert_eq!(bands(&s, name), before, "{name}"); } std::fs::remove_dir_all(dir).unwrap(); } /// An ASCII grid whose `.prj` can't be read is added in longitude/latitude, and adding it says so; /// one whose `.prj` is read says nothing of it. #[test] fn projection_review_typed_layer_inputs_report_the_formula_choice() { let wkt = include_str!("../../data/src/raster/wkt2_corpus.tsv") .lines() .find(|row| row.starts_with("EPSG:4087\t")) .unwrap() .split('\t') .nth(5) .unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-typed-eqc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (definition, expected) in [(wkt, 1), ("EPSG:4087", 0)] { for mode in ["define", "csv", "export"] { let mut session = world(); let mark = session.notice_mark(); match mode { "define" => { session.run("layer.defineProjection", &json!({"layer":"Cities","crs":definition})).unwrap(); } "csv" => { let path = dir.join("point.csv"); std::fs::write(&path, "e,n\n0,5009377.085697311\n").unwrap(); let result = session.run("layer.addData", &json!({"path":path,"x":"e","y":"n","crs":definition})).unwrap(); let layer = session.map().layer(result["layer"].as_u64().unwrap() as u32).unwrap(); assert!((layer.dataset.features[0].geometry.as_ref().unwrap().label_point().unwrap().y - 45.0).abs() < 1e-9); } _ => { session .run( "share.exportLayer", &json!({"layer":"Cities","path":dir.join(format!("export-{expected}.shp")),"crs":definition}), ) .unwrap(); } } assert_eq!( session.notices_since(mark).iter().filter(|notice| notice.text.contains("EPSG method 1028")).count(), expected, "{mode}: {:?}", session.notices_since(mark) ); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn projection_review_layout_grid_reports_the_formula_choice() { let wkt = include_str!("../../data/src/raster/wkt2_corpus.tsv") .lines() .find(|row| row.starts_with("EPSG:4087\t")) .unwrap() .split('\t') .nth(5) .unwrap(); for (definition, expected) in [(wkt, 1), ("EPSG:4087", 0)] { let mut session = world(); session.run("layout.new", &json!({"template":"map"})).unwrap(); let frame = session.project.layouts[0].elements.iter().find(|element| element.kind.code() == "mapFrame").unwrap().id; for (command, params) in [ ("layout.addElement", json!({"kind":"grid","type":"measured","crs":definition})), ("layout.update", json!({"element":frame,"grid":{"type":"measured","crs":definition}})), ] { let mark = session.notice_mark(); session.run(command, ¶ms).unwrap(); assert_eq!( session.notices_since(mark).iter().filter(|notice| notice.text.contains("EPSG method 1028")).count(), expected, "{command}" ); } } } #[cfg(not(target_arch = "wasm32"))] #[test] fn projection_review_zarr_parameters_report_the_formula_choice() { let _cache = zarr_cache_lock(); let wkt = include_str!("../../data/src/raster/wkt2_corpus.tsv") .lines() .find(|row| row.starts_with("EPSG:4087\t")) .unwrap() .split('\t') .nth(5) .unwrap(); let (dir, path) = zarr_store_over_time("eqc-input-notice"); // Projected coordinates with no declared CRS, so the supplied definition is actually used. let store = std::path::Path::new(&path); let mut metadata: Value = serde_json::from_str(&std::fs::read_to_string(store.join(".zmetadata")).unwrap()).unwrap(); let objects = metadata["metadata"].as_object_mut().unwrap(); for (old, new) in [("lat", "y"), ("lon", "x")] { let array = objects.remove(&format!("{old}/.zarray")).unwrap(); objects.insert(format!("{new}/.zarray"), array); objects.remove(&format!("{old}/.zattrs")); objects.insert(format!("{new}/.zattrs"), json!({"_ARRAY_DIMENSIONS":[new],"units":"m"})); std::fs::rename(store.join(old), store.join(new)).unwrap(); } objects.get_mut("t/.zattrs").unwrap()["_ARRAY_DIMENSIONS"] = json!(["time", "y", "x"]); std::fs::write(store.join(".zmetadata"), metadata.to_string()).unwrap(); for (definition, expected) in [(wkt, 1), ("EPSG:4087", 0)] { for following in [false, true] { let mut session = Session::new(Project::default()); let result = session .run("layer.addZarr", &json!({"path":path,"variable":"t","index":{"time":0},"crs":definition,"time":following})) .unwrap(); let layer = session.map().layer(result["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(layer.raster.as_ref().unwrap().data.as_ref().unwrap().crs, rustgis_data::raster::Crs::parse(definition).unwrap()); assert_eq!( session.notices.iter().filter(|notice| notice.text.contains("EPSG method 1028")).count(), expected, "following={following}: {:?}", session.notices ); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn projection_review_ascii_grid_keeps_its_datum_notice() { let dir = std::env::temp_dir().join(format!("rustgis-ascii-datum-note-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("nad27.asc"); std::fs::write(&path, "ncols 1\nnrows 1\nxllcorner -80\nyllcorner 40\ncellsize 1\n7\n").unwrap(); std::fs::write(path.with_extension("prj"), r#"GEOGCS["GCS_North_American_1927",DATUM["D_North_American_1927",SPHEROID["Clarke_1866",6378206.4,294.9786982]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#).unwrap(); let mut session = Session::new(Project::default()); session.run("layer.addData", &json!({"path":path})).unwrap(); assert_eq!(session.notices.iter().filter(|notice| notice.text.contains("has no conversion to WGS 84")).count(), 1); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn ascii_grid_reports_declared_method_1028_compatibility() { let dir = std::env::temp_dir().join(format!("rustgis-ascii-eqc-note-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let projected = include_str!("../../data/src/raster/wkt2_corpus.tsv") .lines() .find(|row| row.starts_with("EPSG:4087\t")) .unwrap() .split('\t') .nth(5) .unwrap(); let geographic = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#; for (name, wkt, y, expected) in [("projected", projected, 5_009_377.085_697_311, 1), ("geographic", geographic, 40.0, 0)] { let path = dir.join(format!("{name}.asc")); std::fs::write(&path, format!("ncols 1\nnrows 1\nxllcorner 0\nyllcorner {y}\ncellsize 1\n7\n")).unwrap(); std::fs::write(path.with_extension("prj"), wkt).unwrap(); let mut session = Session::new(Project::default()); session.run("layer.addData", &json!({"path":path})).unwrap(); let notices = session.notices.iter().filter(|notice| notice.text.contains("EPSG method 1028")).collect::>(); assert_eq!(notices.len(), expected, "{name}: {:?}", session.notices); assert!(notices.iter().all(|notice| notice.text.contains("the .prj file") && !notice.text.contains("read as longitude/latitude"))); if expected > 0 { let raster = rustgis_data::raster::read_path(&path).unwrap(); let point = raster.crs.transformer().unwrap().to_lonlat(rustgis_data::Coord::new(0.0, y)).unwrap(); assert!((point.y - 45.0).abs() < 1e-9); } } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn an_ascii_grid_s_unread_prj_is_said_when_added() { let dir = std::env::temp_dir().join(format!("rustgis-engine-asc-prj-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); for (name, prj, said) in [ ( "geocentric", r#"GEOCCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["metre",1]]"#, true, ), ( "wgs84", r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#, false, ), ] { let path = dir.join(format!("{name}.asc")); std::fs::write(&path, "ncols 2\nnrows 2\nxllcorner 10\nyllcorner 40\ncellsize 1\n1 2\n3 4\n").unwrap(); std::fs::write(dir.join(format!("{name}.prj")), prj).unwrap(); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": path})).unwrap(); let notes: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).filter(|t| t.contains("the .prj file")).collect(); assert_eq!(notes.len(), usize::from(said), "{name}: {:?}", s.notices_since(mark)); assert!(notes.iter().all(|n| n.contains(name) && n.contains("read as longitude/latitude")), "{notes:?}"); } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn layers_export_to_geopackage_geoparquet_shapefile_and_kmz() { let dir = std::env::temp_dir().join(format!("rustgis-engine-export-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let want = s.map().layers.iter().find(|l| l.name == "Countries").unwrap().dataset.features.len(); for ext in ["gpkg", "parquet", "shp", "kmz", "zip"] { let path = dir.join(format!("countries.{ext}")).display().to_string(); let r = s.run("share.exportLayer", &json!({"layer": "Countries", "path": path})).unwrap(); assert_eq!((r["features"].as_u64(), r["leftOut"].as_u64()), (Some(want as u64), Some(0)), "{ext}"); if matches!(ext, "kmz" | "zip") { assert!(std::fs::metadata(&path).unwrap().len() > 0); continue; } // Added back, drawn and saved in a project that reopens. let r = s.run("layer.addData", &json!({"path": path})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; let l = s.map().layer(id).unwrap(); let n = if l.is_streamed() { l.load_all().unwrap().features.len() } else { l.dataset.features.len() }; assert_eq!(n, want, "{ext}"); } let project = dir.join("p.rgis").display().to_string(); s.run("project.saveAs", &json!({"path": project})).unwrap(); s.run("project.open", &json!({"path": project})).unwrap(); s.run("share.exportMap", &json!({"path": dir.join("m.png").display().to_string()})).unwrap(); // Not a format it writes. assert!(s.run("share.exportLayer", &json!({"layer": "Countries", "path": dir.join("x.dwg").display().to_string()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn layers_export_in_the_system_they_were_read_in() { // Issue #514: squares in ETRS89 / UTM zone 32N came out of every export in EPSG:4326. let dir = std::env::temp_dir().join(format!("rustgis-engine-export-crs-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let csv = dir.join("squares.csv"); std::fs::write(&csv, "side;wkt\n1 km;POLYGON ((500000 5700000, 501000 5700000, 501000 5701000, 500000 5701000, 500000 5700000))\n") .unwrap(); let mut s = Session::new(Project::default()); let add = json!({"path": csv.display().to_string(), "name": "squares", "wkt": "wkt", "crs": "EPSG:25832", "delimiter": ";"}); s.run("layer.addData", &add).unwrap(); let path = |name: &str| dir.join(name).display().to_string(); for (file, crs, want) in [ ("out.gpkg", None, "EPSG:25832"), ("out.shp", None, "EPSG:25832"), ("out.fgb", None, "EPSG:25832"), ("out4647.gpkg", Some("EPSG:4647"), "EPSG:4647"), ("out4326.shp", Some("EPSG:4326"), "EPSG:4326"), ("out.geojson", None, "EPSG:4326"), ] { let mut p = json!({"layer": "squares", "path": path(file)}); if let Some(c) = crs { p["crs"] = json!(c); } let r = s.run("share.exportLayer", &p).unwrap(); assert_eq!(r["crs"], want, "{file}"); // Added back, it is in the same place and remembers its system. let r = s.run("layer.addData", &json!({"path": path(file)})).unwrap(); let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); let ds = if l.is_streamed() { l.load_all().unwrap() } else { l.dataset.clone() }; assert_eq!(ds.crs.map(|c| c.label()).unwrap_or_else(|| "EPSG:4326".into()), want, "{file}"); let e = ds.extent(); assert!((e.min_x - 9.0).abs() < 1e-6 && (e.min_y - 51.451_18).abs() < 1e-5 && e.width() > 0.0139, "{file}: {e:?}"); } // The .prj says the system, by its parameters and its code. let prj = std::fs::read_to_string(dir.join("out.prj")).unwrap(); assert!(prj.contains("PARAMETER[\"central_meridian\",9]") && prj.contains("AUTHORITY[\"EPSG\",\"25832\"]"), "{prj}"); // GeoJSON is longitude/latitude only; an unknown system is an error. assert!(s.run("share.exportLayer", &json!({"layer": "squares", "path": path("x.geojson"), "crs": "EPSG:25832"})).is_err()); assert!(s.run("share.exportLayer", &json!({"layer": "squares", "path": path("x.gpkg"), "crs": "EPSG:1"})).is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn zipped_shapefiles_add_as_layers() { use base64::Engine; // The sample's countries and cities as Shapefiles in one zip, under a folder. let dir = std::env::temp_dir().join(format!("rustgis-engine-zip-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut entries = Vec::new(); for (name, text) in [("countries", crate::sample::countries()), ("cities", crate::sample::CITIES)] { let (ds, _) = rustgis_data::io::read_bytes(&format!("{name}.geojson"), text.as_bytes()).unwrap(); let (zipped, _) = rustgis_data::io::write_bytes(&ds, "shp").unwrap(); for (n, b) in rustgis_data::zip::read(&zipped).unwrap() { entries.push((format!("world/{n}"), b)); } } let zip = rustgis_data::zip::write(&entries).unwrap(); // Uploaded (the web app). let mut s = Session::new(Project::default()); let data = base64::engine::general_purpose::STANDARD.encode(&zip); let r = s.run("layer.addDataBytes", &json!({"fileName": "world.zip", "data": data})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(2), "{r}"); let names: Vec<&str> = s.map().layers.iter().map(|l| l.name.as_str()).collect(); assert!(names.contains(&"cities") && names.contains(&"countries"), "{names:?}"); // From disk (the desktop app), saved and reopened with the project. let path = dir.join("world.zip"); std::fs::write(&path, &zip).unwrap(); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": path.display().to_string(), "name": "Towns"})).unwrap(); assert!(r["features"].as_u64().unwrap() > 10, "{r}"); assert_eq!(s.map().layers.iter().filter(|l| l.name == "Towns").count(), 1); let project = dir.join("p.rgis").display().to_string(); s.run("project.saveAs", &json!({"path": project})).unwrap(); s.run("project.open", &json!({"path": project})).unwrap(); assert_eq!(s.map().layers.len(), 2); let _ = std::fs::remove_dir_all(&dir); } /// Serves each request by the first route its path starts with (`{base}` in a body becomes the /// server's address; other paths are 404); returns the base address. fn serve_routes(routes: Vec<(&'static str, String)>, n: usize) -> String { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let b = base.clone(); std::thread::spawn(move || { for stream in listener.incoming().take(n) { let Ok(mut s) = stream else { continue }; let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]); let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); match routes.iter().find(|(p, _)| path.starts_with(p)) { Some((_, body)) => { let body = body.replace("{base}", &b); let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); } None => { let _ = write!(s, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); } } } }); base } /// A STAC API (a local one): its collections, a search adding the footprints, and an item's /// asset added by its address; assets elsewhere than the web are refused by name. #[test] fn stac_search_and_assets() { let item = r#"{"type": "Feature", "id": "S2_1", "collection": "s2", "bbox": [7, 46, 8, 47], "geometry": {"type": "Polygon", "coordinates": [[[7, 46], [8, 46], [8, 47], [7, 47], [7, 46]]]}, "properties": {"datetime": "2024-06-02T10:30:00Z", "eo:cloud_cover": 3.5}, "links": [{"rel": "self", "href": "{base}/v1/collections/s2/items/S2_1"}], "assets": {"places": {"href": "{base}/data/places.geojson", "type": "application/geo+json", "roles": ["data"]}, "b04": {"href": "s3://bucket/B04.tif", "type": "image/tiff; application=geotiff", "roles": ["data"]}}}"#; let geojson = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [7.5, 46.5]}}]}"#; let base = serve_routes( vec![ ("/v1/collections/s2/items/", item.to_string()), ("/v1/collections", r#"{"collections": [{"id": "s2", "title": "Sentinel-2"}]}"#.to_string()), ( "/v1/search", format!( r#"{{"type": "FeatureCollection", "features": [{item}], "links": [{{"rel": "next", "href": "{{base}}/v1/search?page=2"}}]}}"# ), ), ("/data/places.geojson", geojson.to_string()), ], 6, ); let api = format!("{base}/v1"); let mut s = Session::new(Project::default()); let r = s.run("stac.collections", &json!({"url": api})).unwrap(); assert_eq!(r["collections"][0]["id"], "s2"); let r = s.run("stac.search", &json!({"url": format!("{api}/search"), "collections": "s2", "bbox": [7, 46, 8, 47], "datetime": "2024-06-01/2024-06-30", "maxCloud": 20})).unwrap(); assert_eq!(r["count"], 1); assert_eq!(r["next"], format!("{base}/v1/search?page=2")); let l = s.map().layer_by_name("STAC footprints").unwrap(); assert_eq!(l.dataset.features.len(), 1); assert_eq!(l.dataset.geometry_type, rustgis_data::GeometryType::Polygon); // The item as the search gave it: its data asset (no visual one, no GeoTIFF on the web). let found = r["items"][0].clone(); let e = s.run("stac.addAsset", &json!({"item": found, "asset": "b04"})).unwrap_err(); assert!(e.contains("s3://"), "{e}"); // Given whole, an item came from no address: its assets may only be public, and this one is // on the local network. let e = s.run("stac.addAsset", &json!({"item": found})).unwrap_err(); assert!(e.contains("local network"), "{e}"); // By the item's address (a server the user named): its local assets are fine. let r = s.run("stac.addAsset", &json!({"url": format!("{api}/collections/s2/items/S2_1"), "name": "Again"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.map().layer_by_name("Again").is_some()); for bad in [ json!({"url": api, "bbox": [0, 50, 1, 40]}), json!({"url": api, "datetime": "last week"}), json!({"url": "file:///etc"}), json!({"item": 7}), json!({"item": {"type": "Feature", "id": "x", "assets": {}}}), ] { assert!(s.run("stac.search", &bad).is_err() || s.run("stac.addAsset", &bad).is_err(), "{bad}"); } assert!(s.run("stac.addAsset", &json!({"item": {"type": "Feature", "id": "x", "assets": {}}})).is_err()); } /// A single STAC asset whose metadata names codes for a failed retrieval (JAXA Earth's /// `je:rasters` `dn.error`) draws them as values, and says so; an asset without them says nothing. #[test] fn a_stac_asset_with_retrieval_codes_says_they_draw_as_values() { let item = |id: &str, extra: &str| { format!( r#"{{"type": "Feature", "id": "{id}", "collection": "c", "bbox": [7, 46, 8, 47], "geometry": null, "properties": {{"datetime": "2024-06-02T10:30:00Z"}}, "assets": {{"places": {{"href": "{{base}}/data/places.geojson", "type": "application/geo+json", "roles": ["data"]{extra}}}}}}}"# ) }; let geojson = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [7.5, 46.5]}}]}"#; let base = serve_routes( vec![ ("/items/clean", item("clean", "")), ("/items/coded", item("coded", r#", "je:rasters": {"dn": {"error": [5.0, 7.0]}}"#)), ("/items/unread", item("unread", r#", "je:rasters": {"dn": {"error": [5.0, "x"]}}"#)), ("/data/places.geojson", geojson.to_string()), ], 6, ); let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("stac.addAsset", &json!({"url": format!("{base}/items/clean"), "name": "Clean"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(!said.iter().any(|t| t.contains("failed retrieval")), "clean input tells nothing: {said:?}"); let mark = s.notice_mark(); s.run("stac.addAsset", &json!({"url": format!("{base}/items/coded"), "name": "Coded"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("Coded: its metadata marks failed retrievals with 2 code(s) (5, 7)")), "{said:?}"); // Codes that can't be read whole (a word among them) say so. let mark = s.notice_mark(); s.run("stac.addAsset", &json!({"url": format!("{base}/items/unread"), "name": "Unread"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter().any(|t| t.contains("Unread: its metadata's codes for a failed retrieval (je:rasters dn.error) couldn't be read whole")), "{said:?}" ); } /// The Print Map PDF keeps a map within the vector budget as vectors and says nothing, a /// colour-vision view too (exports don't simulate it, so it needn't be a picture). #[test] fn the_map_pdf_keeps_vectors_and_says_nothing_with_or_without_colour_vision() { let mut s = Session::new(crate::sample::world_project()); for mode in ["normal", "deuteranopia"] { s.run("map.colorVision", &json!({"mode": mode})).unwrap(); let mark = s.notice_mark(); let pdf = crate::cmd::share::map_pdf(&mut s, 96.0).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(!said.iter().any(|t| t.contains("one picture")), "{mode}: {said:?}"); let text = String::from_utf8_lossy(&pdf); assert!(!text.contains("/Subtype /Image"), "{mode}: the map as vectors, not a picture"); } } /// Directions through a routing server of one's own (a local Valhalla answer here): the route /// and its steps as layers; the app's answer can be passed in instead, and nothing is fetched. #[test] fn directions_between_places() { const ANSWER: &str = r#"{"code":"Ok","routes":[{"duration":302.776,"distance":1921.553,"legs":[{"summary":"Pariser Platz","steps":[ {"geometry":"g~idcBwjpoXl@InGo@","name":"Pariser Platz","distance":17.553,"duration":6.327,"maneuver":{"instruction":"Drive south on Pariser Platz.","type":"depart","location":[13.378236,52.51634]}}, {"geometry":"itidcBqlpoXiD}mAYsKe@cS","name":"Pariser Platz","distance":1904.0,"duration":296.448,"maneuver":{"modifier":"left","instruction":"Turn left to stay on Pariser Platz.","type":"continue","location":[13.378265,52.516181]}}, {"geometry":"ycqdcBurdqX??","name":"","distance":0.0,"duration":0.0,"maneuver":{"instruction":"You have arrived at your destination.","type":"arrive","location":[13.404987,52.520013]}}]}]}]}"#; let base = serve(ANSWER.as_bytes(), 1); let mut s = Session::new(Project::default()); let r = s .run("map.route", &json!({"from": "52.5163, 13.3777", "to": [13.405, 52.52], "via": [{"lon": 13.39, "lat": 52.517}], "mode": "drive", "avoid": ["tolls"], "url": base})) .unwrap(); assert_eq!(r["duration"], "5 min"); assert_eq!(r["steps"].as_array().map(Vec::len), Some(3)); assert_eq!(r["steps"][1]["modifier"], "left"); let line = s.map().layer_by_name("Route (1.9 km)").unwrap(); assert_eq!(line.dataset.geometry_type, rustgis_data::GeometryType::Polyline); let steps = s.map().layer_by_name("Route steps").unwrap(); assert_eq!(steps.dataset.features.len(), 3); // The camera went to the route. let c = s.map().to_lonlat(s.map().camera.center).unwrap(); assert!((c.x - 13.39).abs() < 0.05 && (c.y - 52.518).abs() < 0.05, "{c:?}"); // The app's answer: nothing is fetched (the server above answered once only). let r = s.run("map.route", &json!({"answer": ANSWER, "mode": "walk", "zoom": false})).unwrap(); assert!(r["layer"].is_u64()); assert!(s.map().layer_by_name("Route (1.9 km) (2)").is_some()); for bad in [ json!({}), json!({"from": [13.0, 52.0]}), json!({"from": [200, 0], "to": [0, 0], "url": base}), json!({"from": [0, 0], "to": [1, 1], "mode": "teleport"}), json!({"from": [0, 0], "to": [1, 1], "avoid": ["mountains"]}), json!({"from": [0, 0], "to": [1, 1], "via": 7}), json!({"from": [0, 0], "to": [1, 1], "url": "file:///etc/passwd"}), json!({"answer": ""}), json!({"answer": ANSWER, "route": 3}), json!({"answer": ANSWER, "route": u64::MAX}), // More names than one call looks up (refused before any is). json!({"from": "a", "to": "b", "via": ["c", "d", "e", "f"]}), ] { assert!(s.run("map.route", &bad).is_err(), "{bad}"); } } /// Places found by name, and a table's addresses turned into points, through a geocoder of one's /// own (a local Nominatim answer here); the app's answers can be passed in instead. #[test] fn geocoding_places_and_addresses() { use crate::render::rustgis_tiles::geocode::Geocoder; const ANSWER: &[u8] = br#"[{"lat": "48.8584", "lon": "2.2945", "display_name": "Tour Eiffel, Paris", "boundingbox": ["48.857", "48.859", "2.293", "2.296"]}]"#; let base = serve(ANSWER, 4); let mut s = Session::new(Project::default()); let r = s.run("map.findPlace", &json!({"query": "Eiffel Tower", "url": base})).unwrap(); assert_eq!(r["count"], 1); assert_eq!(r["places"][0]["name"], "Tour Eiffel, Paris"); assert!(s.run("map.findPlace", &json!({"query": " "})).is_err()); assert!(s.run("map.findPlace", &json!({"query": "x", "provider": "google"})).is_err()); // Two rows share an address, asked once; a row without one has no point. let csv = "id,street,city\n1,Champ de Mars,Paris\n2,Champ de Mars,Paris\n3,5 Av. Anatole,Paris\n4,,\n"; s.run("layer.addDataText", &json!({"fileName": "sites.csv", "text": csv})).unwrap(); assert!(s.project.tables.iter().any(|t| t.name == "sites"), "a table of addresses"); let r = s.run("layer.geocode", &json!({"table": "sites", "address": ["street", "city"], "url": base})).unwrap(); assert_eq!((r["matched"].as_u64(), r["unmatched"].as_u64()), (Some(3), Some(1)), "{r}"); let l = s.map().layer_by_name("sites (geocoded)").unwrap(); assert_eq!(l.dataset.features.len(), 4); assert_eq!(l.dataset.fields.last().unwrap().name, "geocoded"); assert!(l.dataset.features[3].geometry.is_none()); // The app's own answers: one address found, the other not. let places = json!({"Champ de Mars, Paris": {"lon": 2.29, "lat": 48.85, "name": "Champ de Mars"}, "5 Av. Anatole, Paris": null}); let r = s.run("layer.geocode", &json!({"layer": "sites", "address": "street, city", "places": places, "name": "G"})).unwrap(); assert_eq!((r["matched"].as_u64(), r["unmatched"].as_u64()), (Some(2), Some(2))); for bad in [json!({"layer": "sites"}), json!({"layer": "sites", "address": "nope"}), json!({"layer": "sites", "address": "id", "places": 7})] { assert!(s.run("layer.geocode", &bad).is_err(), "{bad}"); } // A server that refuses: the requests are failures, not "not found". let refusing = format!("{}/missing", serve(ANSWER, 2)); let e = s.run("layer.geocode", &json!({"table": "sites", "address": ["street", "city"], "url": refusing})).unwrap_err(); assert!(e.contains("none of the addresses"), "{e}"); assert!(s.notices.iter().any(|n| n.error && n.text.contains("2 addresses couldn't be looked up"))); // A public server takes a hundred addresses a run. let g = Geocoder::new(None, None).unwrap(); assert!(crate::cmd::geocode::check_batch(&g, 101).unwrap_err().contains("100")); } /// PDOK's Locatieserver as the geocoder (a local server answering as it does): places found by /// name, a table's Dutch addresses turned into points, and the answers' entries without a usable /// point counted in a notice (and no notice when every entry has one). #[test] fn geocoding_with_the_locatieserver() { use crate::render::rustgis_tiles::geocode::Geocoder; let clean = br#"{"response":{"numFound":2,"start":0,"docs":[ {"weergavenaam":"Damrak 1, 1012LG Amsterdam","centroide_ll":"POINT(4.89803846 52.37714446)"}, {"weergavenaam":"Damrak, Amsterdam","centroide_ll":"POINT(4.89580599 52.37513402)"}]}}"#; let lossy = br#"{"response":{"numFound":2,"start":0,"docs":[ {"weergavenaam":"Coolsingel, Rotterdam","centroide_ll":"POINT(4.4795)"}, {"weergavenaam":"Coolsingel 40, 3011AD Rotterdam","centroide_ll":"POINT(4.47920127 51.92272076)"}]}}"#; let nothing = br#"{"response":{"numFound":0,"start":0,"docs":[]}}"#; // Each request's path and query exactly as the Locatieserver kind builds them. let path = |q: &str, limit: usize| { let g = Geocoder::new(Some("locatieserver"), Some("http://127.0.0.1")).unwrap(); g.url(q, limit).trim_start_matches("http://127.0.0.1").to_string() }; let (base, asked) = serve_files(vec![ (path("Damrak 1, Amsterdam", 5), clean.to_vec()), (path("Damrak 1, Amsterdam", 1), clean.to_vec()), (path("Coolsingel 40, Rotterdam", 1), lossy.to_vec()), (path("Nergensstraat 9, Nergenshuizen", 1), nothing.to_vec()), (path("Kapot", 5), br#"{"response":{"docs":[{"weergavenaam":"x","centroide_ll":"POINT(x y)"}]}}"#.to_vec()), ]); let url = format!("{base}/free"); let left_out = |s: &Session| s.notices.iter().filter(|n| n.text.contains("no usable position")).map(|n| n.text.clone()).collect::>(); let mut s = Session::new(Project::default()); let r = s.run("map.findPlace", &json!({"query": "Damrak 1, Amsterdam", "provider": "locatieserver", "url": url})).unwrap(); assert_eq!(r["count"], 2, "{r}"); assert_eq!(r["places"][0]["name"], "Damrak 1, 1012LG Amsterdam"); assert_eq!((r["places"][0]["lon"].as_f64(), r["places"][0]["lat"].as_f64()), (Some(4.89803846), Some(52.37714446))); // The camera went to the first place, longitude and latitude the right way round. let c = s.map().to_lonlat(s.map().camera.center).unwrap(); assert!((c.x - 4.898).abs() < 0.01 && (c.y - 52.377).abs() < 0.01, "{c:?}"); assert!(left_out(&s).is_empty(), "a clean answer leaves nothing out: {:?}", left_out(&s)); // A table of Dutch addresses: one found, one found after an entry without a usable point // (counted), one not found. let csv = "id,street,city\n1,Damrak 1,Amsterdam\n2,Coolsingel 40,Rotterdam\n3,Nergensstraat 9,Nergenshuizen\n"; s.run("layer.addDataText", &json!({"fileName": "adressen.csv", "text": csv})).unwrap(); let r = s .run("layer.geocode", &json!({"table": "adressen", "address": ["street", "city"], "provider": "locatieserver", "url": url})) .unwrap(); assert_eq!((r["matched"].as_u64(), r["unmatched"].as_u64()), (Some(2), Some(1)), "{r}"); let l = s.map().layer_by_name("adressen (geocoded)").unwrap(); let points: Vec<_> = l.dataset.features.iter().map(|f| f.geometry.clone()).collect(); assert_eq!(points[0], Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(4.89803846, 52.37714446)))); assert_eq!(points[1], Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(4.47920127, 51.92272076)))); assert_eq!(points[2], None); let geocoded = l.dataset.fields.len() - 1; assert_eq!(l.dataset.value(1, geocoded).to_string(), "Coolsingel 40, 3011AD Rotterdam"); assert_eq!(left_out(&s).len(), 1, "one notice for the run: {:?}", left_out(&s)); assert!(left_out(&s)[0].starts_with("1 of the geocoder's places"), "{:?}", left_out(&s)); // An answer whose every place is unusable is an error, not "nothing found"; a query longer // than the Locatieserver takes isn't sent cut short. let e = s.run("map.findPlace", &json!({"query": "Kapot", "provider": "locatieserver", "url": url})).unwrap_err(); assert!(e.contains("none with a usable position"), "{e}"); let e = s.run("map.findPlace", &json!({"query": "a".repeat(401), "provider": "locatieserver", "url": url})).unwrap_err(); assert!(e.contains("up to 400"), "{e}"); // Every request asked the Locatieserver's search, for the kinds of place it is given. let asked = asked.lock().unwrap().clone(); assert_eq!(asked.len(), 5, "{asked:?}"); assert!(asked.iter().all(|p| p.starts_with("/free?q=") && p.contains("&fq=type%3A%28provincie%20OR")), "{asked:?}"); // The public server takes a whole table, a few requests a second. let g = Geocoder::new(Some("locatieserver"), None).unwrap(); assert!(crate::cmd::geocode::check_batch(&g, 5000).is_ok()); assert!(crate::cmd::geocode::check_batch(&g, 10_001).unwrap_err().contains("10000")); assert!(g.spacing_ms() > 0); } /// Addresses longer than the Locatieserver takes, ahead of one it finds: each fails on its own /// (counted in the run's notice) and isn't sent, rather than counting as the server refusing /// requests, which would stop the run before the address it finds. #[test] fn geocoding_skips_addresses_too_long_for_the_server() { use crate::render::rustgis_tiles::geocode::Geocoder; let clean = br#"{"response":{"numFound":1,"start":0,"docs":[ {"weergavenaam":"Damrak 1, 1012LG Amsterdam","centroide_ll":"POINT(4.89803846 52.37714446)"}]}}"#; let g = Geocoder::new(Some("locatieserver"), Some("http://127.0.0.1")).unwrap(); let path = g.url("Damrak 1, Amsterdam", 1).trim_start_matches("http://127.0.0.1").to_string(); let (base, asked) = serve_files(vec![(path, clean.to_vec())]); let mut csv = "id,address\n".to_string(); for (i, c) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() { csv.push_str(&format!("{i},{}\n", c.repeat(401))); } csv.push_str("9,\"Damrak 1, Amsterdam\"\n"); let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "lang.csv", "text": csv})).unwrap(); let r = s .run("layer.geocode", &json!({"table": "lang", "address": "address", "provider": "locatieserver", "url": format!("{base}/free")})) .unwrap(); assert_eq!((r["matched"].as_u64(), r["unmatched"].as_u64()), (Some(1), Some(6)), "{r}"); let failed: Vec<_> = s.notices.iter().filter(|n| n.text.contains("couldn't be looked up")).map(|n| n.text.clone()).collect(); assert_eq!(failed.len(), 1, "{failed:?}"); assert!(failed[0].starts_with("6 addresses") && failed[0].contains("401 characters"), "{failed:?}"); // Only the address that fits was sent. assert_eq!(asked.lock().unwrap().len(), 1); } /// The SQL workspace's commands: a query's first rows without a layer, and a database's columns /// (through DuckDB when it is installed; without it, they say how to get it). #[cfg(not(target_arch = "wasm32"))] #[test] fn sql_workspace_rows_and_schema() { let mut s = Session::new(Project::default()); assert!(s.run("sql.query", &json!({})).is_err()); if !rustgis_data::source::duckdb::available() { assert!(s.run("sql.query", &json!({"sql": "SELECT 1"})).unwrap_err().contains("need DuckDB")); return; } let before = s.map().layers.len(); let r = s.run("sql.query", &json!({"sql": "SELECT i AS n, ST_Point(i, 0) AS geom FROM range(150) t(i)"})).unwrap(); assert_eq!(r["rows"].as_array().map(Vec::len), Some(100), "the default limit"); assert_eq!((r["geometry"].as_bool(), r["more"].as_bool()), (Some(true), Some(true))); assert_eq!(r["columns"][0]["name"], "n"); assert_eq!(r["rows"][2][1], "POINT (2 0)"); // A result the app ran is added only for the query it came from, and kept until it is. let q = "SELECT 'a' AS name, ST_Point(1, 2) AS geom"; let out = rustgis_data::source::duckdb::run(&rustgis_data::source::duckdb::Query { sql: q, ..Default::default() }).unwrap(); let id = s.sql_ran(q, None, out); assert!(s.run("layer.addSql", &json!({"sql": "SELECT 2 AS x", "ran": id})).unwrap_err().contains("another query")); let r = s.run("layer.addSql", &json!({"sql": q, "ran": id, "name": "Ran"})).unwrap(); assert_eq!(r["features"], 1); assert!(s.run("layer.addSql", &json!({"sql": q, "ran": id})).unwrap_err().contains("no longer kept")); s.run("layer.remove", &json!({"layer": "Ran"})).unwrap(); assert_eq!(s.map().layers.len(), before, "no layer is added"); let r = s.run("sql.query", &json!({"sql": "SELECT 'a' AS x", "limit": 5000})).unwrap(); assert_eq!((r["geometry"].as_bool(), r["more"].as_bool()), (Some(false), Some(false))); assert!(s.run("sql.query", &json!({"sql": "SELECT * FROM nowhere"})).unwrap_err().contains("nowhere")); // A database's tables and their columns. let dir = std::env::temp_dir().join(format!("rustgis-engine-sqlws-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("towns.duckdb"); let ok = std::process::Command::new(rustgis_data::source::duckdb::program().unwrap()) .arg(&db) .arg("-c") .arg("CREATE TABLE towns AS SELECT 'x' AS name, 3 AS pop;") .status() .unwrap() .success(); assert!(ok); let r = s.run("sql.schema", &json!({"database": db.display().to_string()})).unwrap(); assert_eq!(r["tables"]["towns"], json!(["name", "pop"])); let r = s.run("sql.query", &json!({"sql": "SELECT pop FROM towns", "database": db.display().to_string()})).unwrap(); assert_eq!(r["rows"], json!([[3]])); let _ = std::fs::remove_dir_all(dir); } /// Serves `body` with byte ranges (`Range: bytes=a-b` answered 206) until the test ends; returns /// the base address and the count of bytes sent. fn serve_ranges(body: Vec) -> (String, std::sync::Arc) { serve_file(body, true) } /// [`serve_ranges`], or (`ranges` false) a server that ignores `Range` and answers every request /// with the whole file. fn serve_file(body: Vec, ranges: bool) -> (String, std::sync::Arc) { serve_gated(body, ranges, None) } /// [`serve_ranges`] refusing the first `n` requests with a 503 and `Retry-After: 1` (a server /// busy for a moment), answering the rest. fn serve_refusing_first(body: Vec, n: usize) -> (String, std::sync::Arc) { serve_with(body, true, None, n, 0) } /// [`serve_ranges`], answering the first `n - 1` requests (a file's header) and refusing the rest /// with a 500: a file whose header reads but whose tiles can't be. fn serve_refusing_from(body: Vec, n: usize) -> (String, std::sync::Arc) { serve_with(body, true, None, 0, n) } /// [`serve_file`] holding every answer until `gate` is set (a request still under way, for as long /// as a test needs). fn serve_gated( body: Vec, ranges: bool, gate: Option>, ) -> (String, std::sync::Arc) { serve_with(body, ranges, gate, 0, 0) } /// [`serve_gated`], refusing the first `refuse` requests (see [`serve_refusing_first`]), and every /// request from the `refuse_from`th on (see [`serve_refusing_from`]; 0 for none). fn serve_with( body: Vec, ranges: bool, gate: Option>, refuse: usize, refuse_from: usize, ) -> (String, std::sync::Arc) { use std::io::{Read, Write}; let refusals = std::sync::Arc::new(std::sync::Mutex::new(refuse)); let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let sent = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let count = sent.clone(); let body = std::sync::Arc::new(body); std::thread::spawn(move || { // Each connection on its own thread: a layer read in place asks for its tiles together, // and answering them one by one would hold a fetch worker for each while the others wait // (the fetch workers are shared by every test in the process). for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; let (body, count, gate, refusals, seen) = (body.clone(), count.clone(), gate.clone(), refusals.clone(), seen.clone()); std::thread::spawn(move || { let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); // Refused from the `refuse_from`th request on: a file whose tiles can't be read. let nth = seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if refuse_from > 0 && nth >= refuse_from { let _ = s.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } // Refused while there are refusals left: busy, ask again in a second. let refused = { let mut left = refusals.lock().unwrap(); let refused = *left > 0; *left = left.saturating_sub(1); refused }; if refused { let _ = s .write_all(b"HTTP/1.1 503 Service Unavailable\r\nRetry-After: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } if let Some(g) = &gate { let start = std::time::Instant::now(); while !g.load(std::sync::atomic::Ordering::Relaxed) && start.elapsed() < std::time::Duration::from_secs(60) { std::thread::sleep(std::time::Duration::from_millis(5)); } } let req = String::from_utf8_lossy(&buf[..read]).to_ascii_lowercase(); // A HEAD request is answered with the length alone. if req.starts_with("head ") { let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); return; } let range = req.lines().filter(|_| ranges).find_map(|l| l.strip_prefix("range: bytes=")).and_then(|r| { let (a, b) = r.trim().split_once('-')?; // A suffix (`-n`): the last n bytes. if a.is_empty() { let n = b.parse::().ok()?.min(body.len()); return Some((body.len() - n, body.len().saturating_sub(1))); } Some((a.parse::().ok()?, b.parse::().ok()?)) }); let (status, part) = match range { Some((a, b)) if a < body.len() => ("206 Partial Content", &body[a..=b.min(body.len() - 1)]), Some(_) => ("416 Range Not Satisfiable", &body[..0]), None => ("200 OK", &body[..]), }; count.fetch_add(part.len(), std::sync::atomic::Ordering::Relaxed); let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", part.len()); let _ = s.write_all(part); }); } }); (format!("http://{addr}"), sent) } /// A static collection laid out by date (JAXA Earth's) as a data cube: no search, the period's /// date folders read and each item's own file taken, with the scale and no-data in `je:rasters`, /// on the time slider and in a trend; a document missing is told, and clean input tells nothing. #[test] fn a_static_collection_by_date_as_a_data_cube() { let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let months = ["2023-12", "2024-01", "2024-02", "2024-03", "2024-04", "2024-05", "2024-06"]; let item = |m: &str| { format!( r#"{{"type": "Feature", "id": "N00.00-N90.00", "collection": "jaxa.cube", "bbox": [0, 0, 90, 90], "geometry": null, "properties": {{"datetime": null, "start_datetime": "{m}-01T00:00:00.000000Z"}}, "assets": {{"V": {{"href": "{cog}/{m}.tiff", "type": "image/tiff; application=geotiff", "je:rasters": {{"dn2value": {{"slope": 0.5, "offset": 0.0}}, "dn": {{"nodata": 255.0, "error": [5.0]}}}}}}}}}}"# ) }; let catalog = |rel: &str, href: &str| format!(r#"{{"type": "Catalog", "links": [{{"rel": "{rel}", "href": "{href}"}}]}}"#); let children: Vec = months.iter().map(|m| format!(r#"{{"rel": "child", "href": "./{m}/catalog.json"}}"#)).collect(); let leak = |t: String| -> &'static str { Box::leak(t.into_boxed_str()) }; let mut routes: Vec<(&'static str, String)> = vec![( "/c/collection.json", format!( r#"{{"type": "Collection", "id": "collection", "links": [{}], "summaries": {{"je:stac_date_format": ["YYYY-MM"], "je:cog_level_max": [1]}}}}"#, children.join(",") ), )]; for m in months { routes.push((leak(format!("/c/{m}/catalog.json")), catalog("child", "./1/catalog.json"))); routes.push((leak(format!("/c/{m}/1/catalog.json")), catalog("child", "./E000.00-E090.00/catalog.json"))); routes.push((leak(format!("/c/{m}/1/E000.00-E090.00/catalog.json")), catalog("item", "./N00.00-N90.00.json"))); if m != "2024-03" { routes.push((leak(format!("/c/{m}/1/E000.00-E090.00/N00.00-N90.00.json")), item(m))); } } let collection_doc = routes[0].1.clone(); let url = format!("{}/c/collection.json", serve_routes(routes, 200)); let cube = |start: &str, end: &str| json!({"url": url, "collection": "jaxa.cube", "asset": "V", "bbox": [10, 45, 11, 46], "start": start, "end": end, "range": [0, 50], "test": "original"}); let mut s = Session::new(Project::default()); let mark = s.notice_mark(); let r = s.run("stac.addCube", &cube("2024-01-01", "2024-02-29")).unwrap(); assert_eq!((r["items"].as_u64(), r["tiles"].as_u64(), r["dates"].as_u64()), (Some(2), Some(1), Some(2)), "{r}"); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(!said.iter().any(|t| t.contains("couldn't be read")), "clean input tells no missing document: {said:?}"); // Its codes for a failed retrieval draw as values on the map, and that is said. assert!(said.iter().any(|t| t.contains("2 file(s) mark failed retrievals with codes")), "{said:?}"); assert!(!said.iter().any(|t| t.contains("left out, as their codes")), "codes read whole leave nothing out: {said:?}"); let tile = &s.map().tiles[0]; assert_eq!(tile.time_urls.len(), 2); assert!(tile.time_urls[0].1.ends_with("/2024-01.tiff#range=0,100"), "the range through je:rasters' slope: {}", tile.time_urls[0].1); // A document missing: its date is left out, and said. let mut gap = Session::new(Project::default()); let mark = gap.notice_mark(); let r = gap.run("stac.addCube", &cube("2024-02-01", "2024-04-30")).unwrap(); assert_eq!(r["dates"], 2, "{r}"); let said: Vec<&str> = gap.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert!(said.iter().any(|t| t.contains("1 document(s) couldn't be read") && t.contains("jaxa.cube")), "{said:?}"); // In a trend, a stored value that is a code for a failed retrieval is no value; the rest are // read through the slope. Pixel (x, y) stores (x + y) % 256, the same at every date. let t = s.run("cube.reduce", &json!({"url": url, "collection": "jaxa.cube", "asset": "V", "bbox": [10, 45, 11, 46], "start": "2024-04-01", "end": "2024-06-30", "stat": "mean"})).unwrap(); let id = t["layers"][0].as_u64().or(t["layer"].as_u64()).unwrap() as u32; let data = s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); let at = |x: usize, y: usize| data.bands[0].data[y * data.width as usize + x]; assert!(at(2, 3).is_nan(), "(2 + 3) is the code: {}", at(2, 3)); assert_eq!(at(3, 3), 3.0, "6 through the slope"); // Another collection's name, and collections that can't make a cube. let mut wrong = cube("2024-01-01", "2024-02-29"); wrong["collection"] = json!("other"); let e = Session::new(Project::default()).run("stac.addCube", &wrong).unwrap_err(); assert!(e.contains("holds jaxa.cube, not other"), "{e}"); let plain = serve_routes(vec![("/s/collection.json", r#"{"type": "Collection", "links": []}"#.to_string())], 2); let mut other = cube("2024-01-01", "2024-02-29"); other["url"] = json!(format!("{plain}/s/collection.json")); let e = Session::new(Project::default()).run("stac.addCube", &other).unwrap_err(); assert!(e.contains("laid out by date"), "{e}"); let normal = serve_routes( vec![("/n/collection.json", r#"{"type": "Collection", "links": [], "summaries": {"je:stac_date_format": ["MM"]}}"#.to_string())], 2, ); other["url"] = json!(format!("{normal}/n/collection.json")); let e = Session::new(Project::default()).run("stac.addCube", &other).unwrap_err(); assert!(e.contains("no year"), "{e}"); // Codes for a failed retrieval that can't be read whole: the item is left out, and said. let bad = item("2024-01").replace(r#""error": [5.0]"#, r#""error": [5.0, "x"]"#); let mut odd = vec![("/c/collection.json", collection_doc)]; odd.push(("/c/2024-01/catalog.json", catalog("child", "./1/catalog.json"))); odd.push(("/c/2024-01/1/catalog.json", catalog("child", "./E000.00-E090.00/catalog.json"))); odd.push(("/c/2024-01/1/E000.00-E090.00/catalog.json", catalog("item", "./N00.00-N90.00.json"))); odd.push(("/c/2024-01/1/E000.00-E090.00/N00.00-N90.00.json", bad)); let odd_url = format!("{}/c/collection.json", serve_routes(odd, 20)); let mut p = cube("2024-01-01", "2024-01-31"); p["url"] = json!(odd_url); let e = Session::new(Project::default()).run("stac.addCube", &p).unwrap_err(); assert!(e.contains("1 item(s) left out, as their codes for a failed retrieval"), "{e}"); } /// A STAC collection's band as a data cube on the time slider (#559), from a local API: a tile /// layer per grid tile, each a file per date; the slider on over their dates; a fixed range in the /// band's own units, through its scale; a tile with no date in the window drawn as nothing. #[test] fn a_stac_data_cube_on_the_time_slider() { let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let item = |id: &str, tile: &str, date: &str, band: bool| { let asset = if band { format!( r#""NDVI": {{"href": "{cog}/{id}.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{{"scale": 0.0001}}]}}"# ) } else { format!(r#""EVI": {{"href": "{cog}/{id}.tif"}}"#) }; format!( r#"{{"type": "Feature", "id": "{id}", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{date}T00:00:00Z", "bdc:tiles": ["{tile}"]}}, "assets": {{{asset}}}}}"# ) }; let features = [ item("a1", "T1", "2024-01-01", true), item("a2", "T1", "2024-01-17", true), item("b1", "T2", "2024-01-01", true), item("b2", "T2", "2024-01-01", true), item("c1", "T3", "2024-01-17", false), ] .join(","); let base = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{features}]}}"#))], 4); let api = format!("{base}/v1"); let mut s = Session::new(Project::default()); let r = s .run( "stac.addCube", &json!({"url": api, "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-31", "range": [0, 0.9]}), ) .unwrap(); assert_eq!( (r["items"].as_u64(), r["tiles"].as_u64(), r["dates"].as_u64(), r["layers"].as_u64(), r["skipped"].as_u64()), (Some(5), Some(2), Some(2), Some(2), Some(1)), "{r}" ); assert_eq!(r["duplicates"], 1, "the second item of T2 on 2024-01-01 is left out and counted"); assert!(!s.notices.iter().any(|n| n.text.contains("failed retrievals")), "no codes, no notice of them"); assert!(crate::cmd::tiles::NETWORK.contains(&"stac.addCube"), "a network command: a plugin needs the network permission to run it"); // An end given as a year or a month takes all of it: "end": "2024-01" reaches 31 January. // Read as 1 January, it would be before this start and refused. let mut by_month = Session::new(Project::default()); let r2 = by_month .run( "stac.addCube", &json!({"url": api, "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-15", "end": "2024-01"}), ) .unwrap(); assert_eq!(r2["dates"], 2, "an end given as a year or a month reaches its last day: {r2}"); // A slider already on takes the cube's dates in: one over other dates gains these two. let mut busy = Session::new(Project::default()); busy.run("time.set", &json!({"dates": ["2023-06-01", "2023-07-01"]})).unwrap(); busy.run( "stac.addCube", &json!({"url": api, "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-31"}), ) .unwrap(); assert_eq!(busy.run("time.info", &Value::Null).unwrap()["steps"], 4, "the slider takes the cube's dates in"); let tiles = s.map().tiles.clone(); assert_eq!(tiles.len(), 2); let t1 = tiles.iter().find(|t| t.name.ends_with("T1")).unwrap(); let t2 = tiles.iter().find(|t| t.name.ends_with("T2")).unwrap(); // A file per date (the first item of a tile and day), the range in stored values (0.9 / 0.0001). assert_eq!(t1.time_urls.len(), 2); assert_eq!(t2.time_urls.len(), 1); assert!(t1.time_urls.iter().all(|(_, u)| u.ends_with("#range=0,9000")), "{:?}", t1.time_urls); assert!(t2.time_urls[0].1.contains("/b1.tif")); // The slider: on, over the two dates; at the first, both tiles draw. assert_eq!(s.run("time.info", &Value::Null).unwrap()["steps"], 2); assert!(t1.draws() && t2.draws() && t1.url.contains("/a1.tif")); // The second date: T1's second file; T2 has none then and draws nothing. s.run("time.goTo", &json!({"step": 1})).unwrap(); let t1 = s.map().tiles.iter().find(|t| t.name.ends_with("T1")).unwrap(); let t2 = s.map().tiles.iter().find(|t| t.name.ends_with("T2")).unwrap(); assert!(t1.url.contains("/a2.tif") && t1.draws(), "{}", t1.url); assert!(!t2.draws()); // Saved and opened again, each tile's dates and the slider's step come back. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().tiles.iter().find(|x| x.name.ends_with("T1")).unwrap(); assert_eq!(again.time_urls.len(), 2); assert!(again.url.contains("/a2.tif"), "{}", again.url); // T2, with no file at that step, draws nothing from the start: opening takes the window. assert!(!t.map().tiles.iter().find(|x| x.name.ends_with("T2")).unwrap().draws()); for bad in [ json!({"url": api, "asset": "ndvi", "start": "2024-01-01", "end": "2024-01-31"}), json!({"url": api, "collection": "cube", "start": "2024-01-01", "end": "2024-01-31"}), json!({"url": api, "collection": "cube", "asset": "ndvi", "start": "2024-02-01", "end": "2024-01-01"}), json!({"url": api, "collection": "cube", "asset": "ndvi", "start": "2024-01-01", "end": "2024-01-31", "range": [1, 0]}), json!({"url": api, "collection": "cube", "asset": "ndvi", "start": "June", "end": "2024-01-31"}), json!({"url": api, "collection": "cube", "asset": "ndvi", "start": "2024-01-01", "end": "2024-01-31", "range": ["a", 0, 1]}), json!({"url": api, "collection": "cube", "asset": "ndvi", "start": "2024-01-01", "end": "2024-01-31", "range": [0, "x", 1]}), ] { assert!(s.run("stac.addCube", &bad).is_err(), "{bad}"); } // A file read whole (a GeoTIFF that isn't cloud-optimised) can't follow the slider: the cube // is refused, and what it added is taken back, the view its first tile framed included. let gt = rustgis_data::raster::GeoTransform { origin_x: 10.0, origin_y: 46.0, pixel_width: 0.5, pixel_height: -0.5 }; let plain = rustgis_data::raster::Raster::new( "p", 2, 2, vec![vec![1.0; 4]], gt, rustgis_data::raster::Crs::LonLat, None, rustgis_data::raster::ColorHint::Gray, "Float32", ) .unwrap(); let (file, _) = serve_ranges(rustgis_data::raster::geotiff::write(&plain, 0).unwrap()); let one = format!( r#"{{"type": "Feature", "id": "p1", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "2024-01-01T00:00:00Z", "bdc:tiles": ["P"]}}, "assets": {{"NDVI": {{"href": "{file}/p.tif"}}}}}}"# ); let first = item("q1", "A", "2024-01-01", true); let plain_api = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{first},{one}]}}"#))], 2); s.zoom_to(&rustgis_geom::Extent::new(-60.0, -30.0, -50.0, -20.0)); let (tiles, layers) = (s.map().tiles.len(), s.map().layers.len()); let (camera, time) = (s.map().camera, s.map().time.clone()); let e = s .run( "stac.addCube", &json!({"url": format!("{plain_api}/v1"), "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-31"}), ) .unwrap_err(); assert!(e.contains("not a cloud-optimised"), "{e}"); assert_eq!((s.map().tiles.len(), s.map().layers.len()), (tiles, layers)); // The view tile A framed and the slider are put back too. assert_eq!(s.map().camera, camera); assert_eq!(s.map().time, time); // Files stored at another scale partway: one range can't fit them all, so it's refused. let rescaled = |id: &str, date: &str, scale: f64| { format!( r#"{{"type": "Feature", "id": "{id}", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{date}T00:00:00Z", "bdc:tiles": ["T1"]}}, "assets": {{"NDVI": {{"href": "{cog}/{id}.tif", "type": "image/tiff; profile=cloud-optimized", "eo:bands": [{{"scale": {scale}}}]}}}}}}"# ) }; let two = [rescaled("r1", "2024-01-01", 0.0001), rescaled("r2", "2024-01-17", 0.001)].join(","); let rescaled_api = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{two}]}}"#))], 2); let e = s .run( "stac.addCube", &json!({"url": format!("{rescaled_api}/v1"), "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-31", "range": [0, 0.9]}), ) .unwrap_err(); assert!(e.contains("same scale and offset"), "{e}"); // Files behind signed addresses (Planetary Computer's) are refused before anything is added. let signed = r#"{"type": "Feature", "id": "pc1", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {"datetime": "2024-01-01T00:00:00Z", "bdc:tiles": ["S"]}, "assets": {"NDVI": {"href": "https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/a.tif"}}}"#; let signed_api = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{signed}]}}"#))], 2); let (tiles, layers) = (s.map().tiles.len(), s.map().layers.len()); let e = s .run( "stac.addCube", &json!({"url": format!("{signed_api}/v1"), "collection": "cube", "asset": "ndvi", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-31"}), ) .unwrap_err(); assert!(e.contains("signed addresses"), "{e}"); assert_eq!((s.map().tiles.len(), s.map().layers.len()), (tiles, layers)); } /// An entry that gives no range, as JAXA Earth's don't, is added with one from a sample of its /// files (`"range": "auto"`): the same for every date. Its credit goes on the tile layers it /// added, and its collection's licence comes back with it. #[cfg(not(target_arch = "wasm32"))] #[test] fn an_entry_without_a_range_takes_one_from_its_files() { use crate::render::rustgis_tiles::cubes; let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let item = |id: &str, date: &str| { format!( r#"{{"type": "Feature", "id": "{id}", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{date}T00:00:00Z", "bdc:tiles": ["T1"]}}, "assets": {{"B1": {{"href": "{cog}/{id}.tif", "eo:bands": [{{"scale": 0.0001}}]}}}}}}"# ) }; let features = [item("a1", "2024-01-01"), item("a2", "2024-01-17")].join(","); let licence = r#"{"type": "Collection", "id": "cube", "license": "proprietary", "links": [{"rel": "license", "href": "https://earth.jaxa.jp/en/data/policy/"}]}"#; let base = serve_routes( vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{features}]}}"#)), ("/v1", licence.to_string())], 8, ); let mut entry = cubes::find("bdc-mod13q1-ndvi").unwrap(); entry.url = format!("{base}/v1"); entry.source = cubes::Source::Stac { collection: "cube".into(), asset: "B1".into() }; (entry.bbox, entry.start, entry.end) = ([0.0, 40.0, 20.0, 50.0], "2023-05-01".into(), Some("2024-12-31".into())); (entry.range, entry.credit) = (None, "JAXA, A test dataset".into()); let mut s = Session::new(Project::default()); let r = crate::cmd::cube_catalog::open_entry(&mut s, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); // The example's values run 0 to 255 (0 to 0.0255 scaled): its 2nd to 98th percentiles. let range: Vec = r["range"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(); assert!(range.len() == 2 && range[0] >= 0.0 && range[0] < 0.002 && range[1] > 0.023 && range[1] <= 0.0256, "{r}"); assert_eq!(r["rangeFrom"], "sample"); let look = format!("#range={},{}", range[0] / 0.0001, range[1] / 0.0001); let tile = &s.map().tiles[0]; assert!(tile.time_urls.len() == 2 && tile.time_urls.iter().all(|(_, u)| u.ends_with(&look)), "{:?} {look}", tile.time_urls); assert_eq!(tile.attribution, "JAXA, A test dataset"); assert_eq!( (r["credit"].as_str(), r["license"]["url"].as_str()), (Some("JAXA, A test dataset"), Some("https://earth.jaxa.jp/en/data/policy/")) ); // Opened again over the same files, its new tile layer (in place of the old one) keeps the credit. crate::cmd::cube_catalog::open_entry(&mut s, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); assert!(s.map().tiles.iter().filter(|t| !t.time_urls.is_empty()).all(|t| t.attribution == "JAXA, A test dataset")); assert!(!s.map().tiles.is_empty()); // A range given takes its place, as for any entry. let mut t = Session::new(Project::default()); let r = crate::cmd::cube_catalog::open_entry(&mut t, &entry, &json!({"bbox": [10, 45, 11, 46], "range": [0, 0.01]})).unwrap(); assert!(r.get("rangeFrom").is_none() && t.map().tiles[0].time_urls.iter().all(|(_, u)| u.ends_with("#range=0,100")), "{r}"); // Other text is refused, naming "auto", before anything is read. assert!( t.run("stac.addCube", &json!({"url": entry.url, "collection": "cube", "asset": "B1", "range": "wide"})) .unwrap_err() .contains("\"auto\"") ); // A credit longer than a project keeps, or one that isn't text, is refused before anything is // read. for a in [json!("x".repeat(501)), json!(42), json!(["x"])] { let e = t.run("stac.addCube", &json!({"url": entry.url, "collection": "cube", "asset": "B1", "attribution": a})).unwrap_err(); assert!(e.contains("at most 500"), "{a}: {e}"); } // Files of classes in their own colours take no range, and a notice says so. use rustgis_data::raster::cog::{Example, example_with}; let (classes, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, ..Example::default() })); let features = [item("a1", "2024-01-01")].join(",").replace(&cog, &classes); let base = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{features}]}}"#))], 4); let mut u = Session::new(Project::default()); let mark = u.notices_given(); let r = u .run( "stac.addCube", &json!({"url": format!("{base}/v1"), "collection": "cube", "asset": "B1", "range": "auto", "bbox": [10, 45, 11, 46], "start": "2023-05-01", "end": "2024-12-31"}), ) .unwrap(); assert!(r.get("range").is_none() && r.get("rangeFrom").is_none(), "{r}"); assert!(u.notices_since(mark).iter().any(|n| n.text.contains("classes drawn in their own colours")), "{:?}", u.notices_since(mark)); // Files whose metadata gives a scale of 0 aren't sampled: no range fits them, and a notice // says each file is stretched on its own (a range can't be left out of a catalog entry). let features = [item("a1", "2024-01-01")].join(",").replace("\"scale\": 0.0001", "\"scale\": 0"); let base = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{features}]}}"#))], 4); let mut z = Session::new(Project::default()); let mark = z.notices_given(); let r = z .run( "stac.addCube", &json!({"url": format!("{base}/v1"), "collection": "cube", "asset": "B1", "range": "auto", "bbox": [10, 45, 11, 46], "start": "2023-05-01", "end": "2024-12-31"}), ) .unwrap(); assert!(r.get("range").is_none() && !z.map().tiles.is_empty(), "{r}"); assert!(z.notices_since(mark).iter().any(|n| n.text.contains("gives a scale of 0")), "{:?}", z.notices_since(mark)); // A collection whose licence can't be read: the cube is still added and credited, and a // notice says the licence wasn't read, pointing to JAXA's terms. Opened again, the licence is // asked for again (this server answers its second request for it). let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, [item("a1", "2024-01-01")].join(",")); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let licence = licence.to_string(); std::thread::spawn(move || { use std::io::{Read, Write}; let mut asked = 0; for stream in listener.incoming().take(16) { let Ok(mut c) = stream else { continue }; let mut buf = [0u8; 4096]; let read = c.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]); let path = req.split_whitespace().nth(1).unwrap_or("/"); let body = if path.starts_with("/v1/search") { Some(search.clone()) } else if path == "/v1" { asked += 1; (asked > 1).then(|| licence.clone()) } else { None }; let _ = match body { Some(b) => write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()), None => write!(c, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"), }; } }); entry.url = format!("{base}/v1"); let mut w = Session::new(Project::default()); let mark = w.notices_given(); let r = crate::cmd::cube_catalog::open_entry(&mut w, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); assert!(r.get("license").is_none() && w.map().tiles[0].attribution == "JAXA, A test dataset", "{r}"); let said = w.notices_since(mark); assert!(said.iter().any(|n| n.text.contains("its collection's licence wasn't read")), "{said:?}"); let mark = w.notices_given(); let r = crate::cmd::cube_catalog::open_entry(&mut w, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); assert_eq!(r["license"]["url"], "https://earth.jaxa.jp/en/data/policy/", "{r}"); let said = w.notices_since(mark); assert!(!said.iter().any(|n| n.text.contains("licence wasn't read")), "{said:?}"); // A licence whose server stalls (here 6 s, then a 404) isn't asked for again at the next // cube.open: its error is given at once, saying when it will be. The stall must outlast // `jaxa::SLOW_READ` (5 s), and the two opens come well within `jaxa::RETRY_AFTER` (10 min). let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = asked.clone(); let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, [item("a1", "2024-01-01")].join(",")); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); std::thread::spawn(move || { use std::io::{Read, Write}; for stream in listener.incoming().take(16) { let Ok(mut c) = stream else { continue }; let mut buf = [0u8; 4096]; let read = c.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]); let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); if path.starts_with("/v1/search") { let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{search}", search.len()); } else { if path == "/v1" { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(6)); let _ = write!(c, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); }); } } }); entry.url = format!("{base}/v1"); let mut w = Session::new(Project::default()); crate::cmd::cube_catalog::open_entry(&mut w, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); let (mark, before) = (w.notices_given(), asked.load(std::sync::atomic::Ordering::SeqCst)); assert!(before >= 1, "the licence was asked for"); let r = crate::cmd::cube_catalog::open_entry(&mut w, &entry, &json!({"bbox": [10, 45, 11, 46]})).unwrap(); assert!(r.get("license").is_none(), "{r}"); assert_eq!(asked.load(std::sync::atomic::Ordering::SeqCst), before, "not asked for again"); let said = w.notices_since(mark); assert!(said.iter().any(|n| n.text.contains("licence wasn't read") && n.text.contains("10 minutes")), "{said:?}"); } /// The data cube catalog (#683): its entries listed by theme, area and words; one opened as typing /// its values into `stac.addCube` would (its collection, band, range and cloud cap filled in, its /// last full year by default), or into `layer.addZarr` following the slider; `cube.trend` and /// `cube.reduce` taking one by `"cube"`; wrong requests refused before anything is added. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_catalog_entry_opens_as_its_command_would() { use crate::render::rustgis_tiles::cubes; let mut s = Session::new(Project::default()); let ids = |r: &Value| r["cubes"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::>(); // An id not in the list, while JAXA Earth's catalog is unread (as here), says so. let e = s.run("cube.open", &json!({"id": "JAXA.Test_daily/B1"})).unwrap_err(); assert!(e.contains("no data cube") && e.contains("hasn't been read in this run"), "{e}"); let all = s.run("cube.catalog", &json!({})).unwrap(); assert_eq!(all["count"].as_u64(), Some(cubes::built_in().len() as u64)); assert_eq!(all["themes"].as_array().map(Vec::len), Some(cubes::THEMES.len())); // MODIS over Piauí: the Brazil Data Cube's NDVI and EVI, each as the list holds it. let modis = s.run("cube.catalog", &json!({"theme": "vegetation", "bbox": [-46, -11, -40, -2.5], "query": "MODIS"})).unwrap(); assert_eq!(ids(&modis), ["bdc-mod13q1-ndvi", "bdc-mod13q1-evi"]); assert_eq!(modis["cubes"][0]["collection"], "mod13q1-6.1"); assert_eq!(modis["cubes"][0]["range"], json!([0.0, 0.9])); // No area given is anywhere, not the map's extent. assert!(!ids(&s.run("cube.catalog", &json!({"query": "era5"})).unwrap()).is_empty()); // A filter of another kind is refused, not dropped (which would list every entry). for bad in [json!({"theme": "forests"}), json!({"theme": ["water"]}), json!({"query": 5}), json!({"bbox": [1, 2]}), json!({"bbox": "here"})] { assert!(s.run("cube.catalog", &bad).is_err(), "{bad}"); } assert!(crate::cmd::tiles::NETWORK.contains(&"cube.open"), "a plugin needs the network permission to open a cube"); // A Zarr entry reaches as layer.addZarr does (a plugin needs files and network); the catalog // itself is built in. use crate::cmd::plugins::{Reach, reaches}; let spec = |id: &str| crate::cmd::all().into_iter().find(|c| c.id == id).unwrap(); assert_eq!(reaches(&spec("cube.open"), &json!({"id": "bdc-mod13q1-ndvi"})), Some(Reach::Network)); assert_eq!(reaches(&spec("cube.open"), &json!({"id": "era5-2m-temperature"})), Some(Reach::Files)); assert_eq!(reaches(&spec("cube.catalog"), &json!({"query": "ndvi"})), None); // An entry over a local STAC API: opened, it makes the cube stac.addCube makes from its values. let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let item = |id: &str, date: &str| { format!( r#"{{"type": "Feature", "id": "{id}", "collection": "cube", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{date}T00:00:00Z", "bdc:tiles": ["T1"]}}, "assets": {{"NDVI": {{"href": "{cog}/{id}.tif", "eo:bands": [{{"scale": 0.0001}}]}}}}}}"# ) }; let features = [item("a1", "2024-01-01"), item("a2", "2024-01-17")].join(","); let base = serve_routes(vec![("/v1/search", format!(r#"{{"type": "FeatureCollection", "features": [{features}]}}"#))], 6); let mut entry = cubes::find("bdc-mod13q1-ndvi").unwrap(); entry.url = format!("{base}/v1"); entry.source = cubes::Source::Stac { collection: "cube".into(), asset: "NDVI".into() }; entry.bbox = [0.0, 40.0, 20.0, 50.0]; entry.start = "2023-05-01".into(); entry.end = Some("2024-12-31".into()); let area = json!({"bbox": [10, 45, 11, 46]}); let mut by_entry = Session::new(Project::default()); let r = crate::cmd::cube_catalog::open_entry(&mut by_entry, &entry, &area).unwrap(); // By default, the entry's last full year: 2024, as it ended that year. assert_eq!( (r["cube"].as_str(), r["start"].as_str(), r["end"].as_str()), (Some("bdc-mod13q1-ndvi"), Some("2024-01-01"), Some("2024-12-31")) ); assert_eq!((r["dates"].as_u64(), r["layers"].as_u64()), (Some(2), Some(1)), "{r}"); let mut typed = Session::new(Project::default()); typed .run( "stac.addCube", &json!({"url": entry.url, "collection": "cube", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-12-31", "range": [0, 0.9]}), ) .unwrap(); let urls = |s: &Session| s.map().tiles.iter().map(|t| t.time_urls.clone()).collect::>(); assert_eq!(urls(&by_entry), urls(&typed), "the same files, the entry's range in stored values"); assert!(urls(&by_entry)[0].iter().all(|(_, u)| u.ends_with("#range=0,9000"))); // As one step: undone, the cube goes. by_entry.run("edit.undo", &Value::Null).unwrap(); assert!(by_entry.map().tiles.is_empty()); // A range and a name given take the entry's place. let r = crate::cmd::cube_catalog::open_entry(&mut by_entry, &entry, &json!({"bbox": [10, 45, 11, 46], "range": [0, 0.5], "name": "NDVI"})) .unwrap(); assert_eq!(r["layers"], 1); assert!(by_entry.map().tiles[0].time_urls.iter().all(|(_, u)| u.ends_with("#range=0,5000"))); assert_eq!(by_entry.map().tiles[0].name, "NDVI"); // Refused before anything is added: an area the entry has no data in, a period outside its // dates, one end of the period alone. let tiles = by_entry.map().tiles.len(); for (bad, why) in [ (json!({"bbox": [100, 0, 101, 1]}), "has data in"), (json!({"bbox": [10, 45, 11, 46], "start": "2020-01-01", "end": "2022"}), "runs from 2023-05-01 to 2024-12-31"), (json!({"bbox": [10, 45, 11, 46], "start": "2025-01-01", "end": "2025-03-01"}), "runs from"), (json!({"bbox": [10, 45, 11, 46], "start": "2024-01-01"}), "together"), ] { let e = crate::cmd::cube_catalog::open_entry(&mut by_entry, &entry, &bad).unwrap_err(); assert!(e.contains(why), "{bad}: {e}"); } assert_eq!(by_entry.map().tiles.len(), tiles); // A Zarr entry: its variable following the slider over the period, named after the entry. let (dir, path) = zarr_store_over_time("catalog"); let mut zarr = cubes::find("era5-2m-temperature").unwrap(); zarr.url = path; zarr.start = "2001-01-01".into(); zarr.source = cubes::Source::Zarr { variable: "t".into() }; let mut z = Session::new(Project::default()); let r = crate::cmd::cube_catalog::open_entry(&mut z, &zarr, &json!({"start": "2001-01-15", "end": "2001-12-31", "bbox": "view"})).unwrap(); let layer = z.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(layer.name, zarr.name); assert!(r["note"].as_str().unwrap().contains("read whole"), "{r}"); assert_eq!(z.run("time.info", &Value::Null).unwrap()["steps"], 2, "the two steps in the period"); // An end given as a year reaches its last moment, as for a STAC entry: all three steps of 2001. let mut year = Session::new(Project::default()); crate::cmd::cube_catalog::open_entry(&mut year, &zarr, &json!({"start": "2001-01-01", "end": "2001"})).unwrap(); assert_eq!(year.run("time.info", &Value::Null).unwrap()["steps"], 3); // A STAC entry's key is refused first, before a period outside the entry's dates. for k in ["range", "maxCloud"] { let e = crate::cmd::cube_catalog::open_entry(&mut z, &zarr, &json!({k: 1, "start": "1990", "end": "1990"})).unwrap_err(); assert!(e.contains("STAC entry's"), "{e}"); } // An area given for a Zarr entry is checked, as for a STAC entry. let mut far = zarr.clone(); far.bbox = [0.0, 0.0, 1.0, 1.0]; let e = crate::cmd::cube_catalog::open_entry(&mut z, &far, &json!({"bbox": [100, 0, 101, 1]})).unwrap_err(); assert!(e.contains("has data in"), "{e}"); let _ = std::fs::remove_dir_all(dir); // cube.open by id: an unknown one, none. assert!(s.run("cube.open", &json!({"id": "nothing"})).unwrap_err().contains("cube.catalog")); assert!(s.run("cube.open", &json!({})).is_err()); // cube.trend and cube.reduce take an entry by "cube": its values filled in, a cell size or // cloud cap given kept; data named alongside it refused. let p = json!({"cube": "bdc-s2-16d-ndvi", "cellSize": 100, "start": "2020"}); let q = crate::cmd::cube_catalog::with_entry(&p).unwrap(); assert_eq!( (q["url"].as_str(), q["collection"].as_str(), q["asset"].as_str(), q["cellSize"].as_f64(), q["start"].as_str(), q.get("cube")), (Some("https://data.inpe.br/bdc/stac/v1/"), Some("S2-16D-2"), Some("NDVI"), Some(100.0), Some("2020"), None) ); let p = json!({"cube": "earth-search-s2-nir"}); let q = crate::cmd::cube_catalog::with_entry(&p).unwrap(); assert_eq!((q["cellSize"].as_f64(), q["maxCloud"].as_f64()), (Some(10.0), Some(20.0))); let p = json!({"cube": "era5-soil-moisture"}); let q = crate::cmd::cube_catalog::with_entry(&p).unwrap(); assert_eq!(q["variable"], "volumetric_soil_water_layer_1"); assert!(q.get("collection").is_none() && q.get("cellSize").is_none()); for (cmd, extra) in [("cube.trend", json!({})), ("cube.reduce", json!({"stat": "mean"}))] { for k in ["layer", "url", "collection"] { let mut p = json!({"cube": "bdc-mod13q1-ndvi", k: "x"}); p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); let e = s.run(cmd, &p).unwrap_err(); assert!(e.contains(&format!("leave out {k}")), "{cmd} {k}: {e}"); } assert!(s.run(cmd, &json!({"cube": "nothing", "stat": "mean"})).unwrap_err().contains("cube.catalog")); } } /// A search's `"bbox": "view"` is the map's view in any case or spacing, as the trend and the data /// cube read it; any other text is refused, never read as no area at all. #[test] fn a_stac_search_takes_view_in_any_case() { let s = world(); let search = |bbox: &str| crate::cmd::stac::search_params(&s, &json!({"bbox": bbox})).unwrap().bbox; assert!(search("view").is_some()); assert_eq!(search(" VIEW "), search("view")); for bad in [json!("nowhere"), json!(7), json!({"w": 1}), json!([10, 45, 11, 46, "extra"]), json!([10, 45, 11, "46"])] { let e = crate::cmd::stac::search_params(&s, &json!({"bbox": bad})).unwrap_err(); assert!(e.contains("bbox is"), "{bad}: {e}"); } assert!(crate::cmd::stac::search_params(&s, &json!({})).unwrap().bbox.is_none(), "no bbox, no area"); } /// A trend given `"bbox": " VIEW "` searches the map's view: its search request carries the box /// the trend reads, not no area at all. #[test] fn a_stac_trend_searches_the_view_it_reads() { use std::io::{Read, Write}; let mut s = world(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let api = format!("http://{}/v1", listener.local_addr().unwrap()); let asked = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let seen = asked.clone(); std::thread::spawn(move || { for mut c in listener.incoming().take(4).flatten() { let mut buf = [0u8; 8192]; let n = c.read(&mut buf).unwrap_or(0); let line = String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string(); seen.lock().unwrap().push(line); let body = r#"{"type": "FeatureCollection", "features": []}"#; let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); } }); let p = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": " VIEW ", "start": "2024", "end": "2024"}); let e = s.run("cube.trend", &p).unwrap_err(); assert!(e.contains("no item"), "the search was made and found nothing: {e}"); let lines = asked.lock().unwrap().clone(); let search = lines.iter().find(|l| l.contains("/search")).unwrap_or_else(|| panic!("{lines:?}")); let query = search.split(['?', ' ']).nth(2).unwrap_or_default(); let bbox = query.split('&').find_map(|kv| kv.strip_prefix("bbox=")).unwrap_or_else(|| panic!("no bbox in {search}")); // The commas as sent, or percent-encoded in either case. let asked: Vec = bbox.replace("%2C", ",").replace("%2c", ",").split(',').map(|v| v.parse().unwrap()).collect(); let view = crate::cmd::stac::search_params(&s, &json!({"bbox": "view"})).unwrap().bbox.unwrap(); assert_eq!(asked.len(), 4, "{search}"); for (a, v) in asked.iter().zip(view) { assert!((a - v).abs() < 1e-6, "{search} against the view {view:?}"); } } /// A trend over a STAC collection's band (#579), read in place from its files over an area: each /// date's values scaled as its own metadata says (here date k's scale is k + 1 ten-thousandths, /// so every cell grows in a straight line), Sen's slope as worked out by hand; the same outputs /// read whole or in pieces of rows; a coarser cell size read from the files' overviews; and the /// requests a trend can't take refused. #[test] fn a_trend_over_a_stac_collection() { use crate::cmd::stac::PIECE_VALUES; let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let dates = ["2024-01-01", "2024-01-17", "2024-02-02", "2024-02-18", "2024-03-05", "2024-03-21"]; let features: Vec = dates .iter() .enumerate() .map(|(k, d)| { format!( r#"{{"type": "Feature", "id": "n{k}", "collection": "mod", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{d}T00:00:00Z", "bdc:tiles": ["h13v09"]}}, "assets": {{"NDVI": {{"href": "{cog}/n{k}.tif", "eo:bands": [{{"scale": {}, "offset": {}}}]}}}}}}"#, (k + 1) as f64 * 1e-4, k as f64 * 0.01 ) }) .collect(); let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, features.join(",")); let base = serve_routes(vec![("/v1/search", search)], 16); let api = format!("{base}/v1"); let params = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "test": "original"}); let mut s = Session::new(Project::default()); let run = |s: &mut Session, p: &Value| { let r = s.run("cube.trend", p).unwrap(); let ids: Vec = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap() as u32).collect(); let raster = |id: u32| s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); (r["dates"].as_u64(), ids.iter().map(|id| raster(*id)).collect::>()) }; let (n, whole) = run(&mut s, ¶ms); assert_eq!(n, Some(6)); let slope = &whole[0]; assert_eq!((slope.width, slope.height), (512, 512), "the area on the full images' grid"); // Pixel (x, y) stores (x + y) % 256; with date k's scale (k + 1) ten-thousandths and offset // k hundredths, its value grows by that many ten-thousandths and a hundredth a date, 16 days // apart: each file's own scale and offset taken. let per_year = |x: usize, y: usize| (((x + y) % 256) as f64 * 1e-4 + 0.01) * 365.2425 / 16.0; for (x, y) in [(10, 20), (300, 100), (0, 0), (511, 511)] { let got = f64::from(slope.bands[0].data[y * 512 + x]); assert!((got - per_year(x, y)).abs() < 1e-4, "({x}, {y}): {got} vs {}", per_year(x, y)); } // A straight rise over six dates is significant, (0, 0)'s too: its stored 0 rises by the offsets. assert!(whole[3].bands[0].data[20 * 512 + 10].is_finite() && whole[3].bands[0].data[0].is_finite()); // An area wider than the file: half a degree more to the west, 256 columns no file covers // (NaN), and the file's own beside them, in their places. let wider = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [9.5, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "test": "original"}); let (_, wide) = run(&mut s, &wider); let ws = &wide[0]; assert_eq!((ws.width, ws.height), (768, 512)); assert!(ws.bands[0].data[20 * 768 + 255].is_nan() && ws.bands[0].data[0].is_nan()); for (x, y) in [(10, 20), (511, 511)] { let got = f64::from(ws.bands[0].data[y * 768 + 256 + x]); assert!((got - per_year(x, y)).abs() < 1e-4, "({x}, {y}): {got} vs {}", per_year(x, y)); } // The parameters read without the network are refused before the search: an API on a // closed port, never asked. for (bad, said) in [ (json!({"bbox": [11, 45, 10, 46]}), "bbox is"), (json!({"cellSize": 0}), "cellSize is"), (json!({"resampling": "bilinear"}), "resampling is"), ] { let mut q = json!({"url": "http://127.0.0.1:9/v1", "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024", "end": "2024"}); for (k, v) in bad.as_object().unwrap() { q[k] = v.clone(); } let e = s.run("cube.trend", &q).unwrap_err(); assert!(e.contains(said), "{e}"); } // An area no file covers (the items only near it) is refused, not four empty rasters. let away = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [20, 45, 21, 46], "start": "2024-01-01", "end": "2024-03"}); let e = s.run("cube.trend", &away).unwrap_err(); assert!(e.contains("covers the area"), "{e}"); // Ten tiles, more than the cover probes reach, none covering it: refused once read, not four // empty rasters. let many: Vec = (0..10) .flat_map(|t| { let cog = cog.clone(); dates.iter().enumerate().map(move |(k, d)| { format!( r#"{{"type": "Feature", "id": "t{t}n{k}", "collection": "mod", "bbox": [20, 45, 21, 46], "geometry": null, "properties": {{"datetime": "{d}T00:00:00Z", "bdc:tiles": ["t{t}"]}}, "assets": {{"NDVI": {{"href": "{cog}/t{t}n{k}.tif"}}}}}}"# ) }) }) .collect(); let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, many.join(",")); let tiles_api = format!("{}/v1", serve_routes(vec![("/v1/search", search)], 16)); let e = s.run("cube.trend", &json!({"url": tiles_api, "collection": "mod", "asset": "NDVI", "bbox": [20, 45, 21, 46], "start": "2024-01-01", "end": "2024-03"})).unwrap_err(); assert!(e.contains("covers the area"), "{e}"); // Read in two pieces (a quarter of the rows' values each would make four; 256-row tiles make // it two), the outputs are the same, cell for cell. PIECE_VALUES.with(|v| v.set(Some(512 * 300 * 6))); let (_, pieces) = run(&mut s, ¶ms); PIECE_VALUES.with(|v| v.set(None)); for (a, b) in whole.iter().zip(&pieces) { assert_eq!((a.width, a.height), (b.width, b.height)); assert!(a.bands[0].data.iter().zip(&b.bands[0].data).all(|(x, y)| x == y || (x.is_nan() && y.is_nan())), "{}", a.name); assert!(crate::cmd::cube::same_grid(&a.transform, &b.transform)); } // A summary of the same cube, whole and in pieces: each cell's mean over the six dates, its // stored v times 3.5 ten-thousandths and 0.025, the same cell for cell. let mut mean = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "stat": "mean"}); let summary = |s: &mut Session, p: &Value| { let r = s.run("cube.reduce", p).unwrap(); s.map().layer(r["layers"][0].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap() }; // Every key a summary of a collection takes is read, none said to be ignored. let mark = s.notice_mark(); let read_whole = summary(&mut s, &mean); let ignored: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).filter(|t| t.contains("ignores")).collect(); assert!(ignored.is_empty(), "{ignored:?}"); assert_eq!((read_whole.width, read_whole.height), (512, 512)); for (x, y) in [(10, 20), (300, 100), (511, 511)] { let got = f64::from(read_whole.bands[0].data[y * 512 + x]); let want = ((x + y) % 256) as f64 * 3.5e-4 + 0.025; assert!((got - want).abs() < 1e-5, "({x}, {y}): {got} vs {want}"); } PIECE_VALUES.with(|v| v.set(Some(512 * 300 * 6))); let in_pieces = summary(&mut s, &mean); // A year at a time: one year here, the same values. mean["by"] = json!("year"); let by_year = summary(&mut s, &mean); PIECE_VALUES.with(|v| v.set(None)); // A STAC cube's dates come as read, eight at a time, so the totals' order (and an output's last // bit) can differ from run to run: the same values, within that. for other in [&in_pieces, &by_year] { let same = |x: &f32, y: &f32| (x - y).abs() <= 1e-6 || (x.is_nan() && y.is_nan()); assert!(read_whole.bands[0].data.iter().zip(&other.bands[0].data).all(|(x, y)| same(x, y))); assert!(crate::cmd::cube::same_grid(&read_whole.transform, &other.transform)); } // Cancelled, the readers stop early and the run says so, rather than work out a block only // partly read. let job = crate::cmd::cube::trend_start(&mut s, ¶ms).unwrap(); let e = job.run(&std::sync::atomic::AtomicBool::new(true), &std::sync::atomic::AtomicUsize::new(0)).unwrap_err(); assert_eq!(e, "cancelled"); // A row too wide for a piece is said. PIECE_VALUES.with(|v| v.set(Some(100))); let e = s.run("cube.trend", ¶ms).unwrap_err(); PIECE_VALUES.with(|v| v.set(None)); assert!(e.contains("more than a run's block"), "{e}"); // A coarser cell size reads the overview nearest it: 256 × 256 cells of 1/256°. let mut coarse = params.clone(); coarse["cellSize"] = json!(0.004); let (_, half) = run(&mut s, &coarse); assert_eq!((half[0].width, half[0].height), (256, 256)); // Averaged, each 1/256° cell is the mean of its 2 × 2 full pixels, (2x + 2y + 1) where the // overview's (nearest) pixel is 2x + 2y. coarse["resampling"] = json!("average"); let (_, mean) = run(&mut s, &coarse); assert_eq!((mean[0].width, mean[0].height), (256, 256)); for (x, y) in [(5, 7), (30, 40)] { let (near, avg) = (f64::from(half[0].bands[0].data[y * 256 + x]), f64::from(mean[0].bands[0].data[y * 256 + x])); let (unit, offsets) = (1e-4 * 365.2425 / 16.0, 0.01 * 365.2425 / 16.0); assert!((near - (2 * x + 2 * y) as f64 * unit - offsets).abs() < 1e-4, "nearest ({x}, {y}): {near}"); assert!((avg - (2 * x + 2 * y + 1) as f64 * unit - offsets).abs() < 1e-4, "average ({x}, {y}): {avg}"); } let before = s.map().layers.len(); for bad in [json!("bilinear"), json!(3)] { coarse["resampling"] = bad; assert!(s.run("cube.trend", &coarse).unwrap_err().contains("resampling")); } assert_eq!(s.map().layers.len(), before, "refused, nothing added"); coarse["resampling"] = Value::Null; // The level nearest the size asked: 0.0025 is nearer the full images' 1/512° than 1/256°. coarse["cellSize"] = json!(0.0025); assert_eq!(run(&mut s, &coarse).1[0].width, 512); // What can't be read is refused before anything is added. let layers = s.map().layers.len(); for (bad, said) in [ (json!({"cellSize": -1}), "cellSize"), (json!({"cellSize": "fine"}), "cellSize"), (json!({"bbox": [11, 45, 10, 46]}), "bbox"), (json!({"asset": null}), "asset"), (json!({"bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-20"}), "dates"), ] { let mut p = params.clone(); for (k, v) in bad.as_object().unwrap() { p[k] = v.clone(); } let e = s.run("cube.trend", &p).unwrap_err(); assert!(e.contains(said), "{bad}: {e}"); } assert_eq!(s.map().layers.len(), layers); } /// A plugin's command in the app (`leave_downloads`) runs on the frame: a dated GeoTIFF's first /// file isn't waited for, and the answer says the map wasn't moved to it. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_plugins_dated_cog_is_added_without_waiting() { // A server that takes the connection and never answers: a probe would wait its minute. let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", server.local_addr().unwrap()); let mut s = Session::new(Project::default()); let start = std::time::Instant::now(); let r = s .run_leaving_downloads( "layer.addUrl", &json!({"url": format!("{base}/{{date:YYYY}}.tif?token=RGSYNTH_dated_cog"), "start": 2020, "end": 2021}), ) .unwrap(); assert!(start.elapsed() < std::time::Duration::from_secs(10), "waited {:?}", start.elapsed()); assert_eq!(r["inPlace"], true, "{r}"); assert!(r["warning"].as_str().is_some_and(|w| w.contains("wasn't moved")), "{r}"); let tile = &s.map().tiles[r["index"].as_u64().unwrap() as usize]; assert!( tile.url.contains(".tif?token=RGSYNTH_dated_cog") && !tile.url.contains("{date"), "the live tile keeps the effective private address" ); let clean = ["warning", "url"].map(|key| r[key].as_str().is_some_and(|text| !text.contains("RGSYNTH_dated_cog"))); assert_eq!(clean, [true, true], "both public warning and URL must omit credentials"); drop(server); } /// A GeoTIFF whose header was just read is framed even if the cache forgot it since (a login /// ended), and one that can't be read again says why the map wasn't moved to it: where the /// session waits for reads in place, the command fails instead (see `add_in_place`). #[cfg(not(target_arch = "wasm32"))] #[test] fn a_probed_cog_is_framed_after_the_cache_forgets_it() { use crate::render::rustgis_tiles::{cache, cog}; let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 128)); let url = format!("{base}/scene.tif"); assert_eq!(cog::probe_blocking(&url), Ok(true)); cache::forget(&url); let mut s = Session::new(Project::default()); let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &url, true).unwrap(); assert!(r["bounds"].is_array(), "{r}"); assert!(r.get("warning").is_none(), "{r}"); // Nothing answers at this address any more: waited for, the command fails. let gone = { let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); format!("http://{}/gone.tif", server.local_addr().unwrap()) }; let e = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true).unwrap_err(); assert!(e.contains("gone.tif"), "{e}"); // Not waited for (the desktop), the layer is added, its read failing as the map draws, and // the answer says why the map wasn't moved to it: a header just read that can't be read // again, and a failure recorded and not yet tried again. Another test's login ending forgets // the record, so it is made again for each try. s.in_place_wait = false; let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true).unwrap(); let warning = r["warning"].as_str().unwrap_or_default(); assert!(warning.contains("couldn't be read (") && warning.contains("wasn't moved"), "{r}"); let said = (0..3).any(|_| { let _ = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, true); let r = crate::cmd::layer::add_cog(&mut s, &json!({}), &gone, false).unwrap(); r["warning"].as_str().is_some_and(|w| w.contains("couldn't be read (") && w.contains("wasn't moved")) }); assert!(said, "a recorded failure went unsaid"); } /// A cloud-optimised GeoTIFF by its address is read in place: its header, then only the tiles a /// map tile needs, by range; a GeoTIFF in strips is downloaded whole as a raster layer. #[test] fn cloud_optimised_geotiffs_are_read_in_place() { use crate::render::rustgis_tiles::cache::{self, Payload, State, Want}; let file = rustgis_data::raster::cog::example(2048, 256); let total = file.len(); let (base, sent) = serve_ranges(file); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif")})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); // A raster layer like a file's: it holds an overview (1024 pixels a side of the 2048), whose // statistics its symbology reads. let l = s.map().layers.iter().find(|l| l.name == "scene").unwrap().clone(); let rl = l.raster.as_ref().unwrap(); assert!(rl.in_place && rl.path.as_deref() == Some(&*format!("{base}/scene.tif"))); let d = rl.data.as_ref().unwrap(); assert_eq!((d.width, d.overview), (1024, Some((2048, 2048)))); assert!(d.bands[0].stats.count > 0 && d.bands[0].stats.max > 200.0); // Framed on the file: 10–11° E, 45–46° N. let b = r["bounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!((b[0] - 10.0).abs() < 1e-6 && (b[3] - 46.0).abs() < 1e-6, "{b:?}"); // Drawn zoomed in past the overview, the part in view is read at full resolution: a few of // its tiles, not the whole file. Pixel (x, y) of the full image is (x + y) % 256: neighbouring // screen pixels differ by one, finer than the overview's steps of two. let before = sent.load(std::sync::atomic::Ordering::Relaxed); let c = rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(10.5, 45.5)); s.map_mut().camera.center = c; // A metre on the ground is about 1.43 Web Mercator units here: a third of a full pixel's. s.map_mut().camera.res = 25.0; s.view_size = (64.0, 64.0); let png = std::env::temp_dir().join(format!("rustgis-cog-in-place-{}.png", std::process::id())); s.run("share.exportMap", &json!({"path": png.to_string_lossy(), "width": 64, "height": 64})).unwrap(); let img = image::open(&png).unwrap().to_rgba8(); let _ = std::fs::remove_file(&png); let row: Vec = (0..64).map(|x| img.get_pixel(x, 32)[0]).collect(); assert!(row.iter().any(|v| *v > 0), "drawn: {row:?}"); assert!(row.windows(2).any(|w| w[1].abs_diff(w[0]) == 1), "full resolution (steps of one), not the overview's (of two): {row:?}"); let window = sent.load(std::sync::atomic::Ordering::Relaxed) - before; assert!(window > 0 && window < total / 8, "{window} of {total} bytes read for the view"); // A map tile inside it (zoom 9) is drawn from a few of its tiles, not the whole file (a dated // series of GeoTIFFs is drawn so, as a tile layer). let (z, lon, lat) = (9u32, 10.5f64, 45.5f64); let n = f64::from(1u32 << z); let x = ((lon + 180.0) / 360.0 * n) as u32; let y = ((1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0 * n) as u32; let tile = format!("cog:{z}/{x}/{y}@{base}/scene.tif"); let before = sent.load(std::sync::atomic::Ordering::Relaxed); let mut got = None; for _ in 0..200 { match cache::get(&tile, Want::Image) { State::Ready(p) => { got = Some(p); break; } State::Failed(e) => panic!("{e}"), State::Pending => cache::wait(std::time::Duration::from_millis(50)), } } let Some(p) = got else { panic!("the tile never came") }; let Payload::Image(pm) = &*p else { panic!("not a picture") }; assert_eq!((pm.width(), pm.height()), (256, 256)); assert!(pm.pixels().iter().any(|px| px.alpha() == 255)); let read = sent.load(std::sync::atomic::Ordering::Relaxed) - before; assert!(read < total / 4, "{read} of {total} bytes read"); // A tile outside it is empty. assert!(matches!(cache::get(&format!("cog:9/0/0@{base}/scene.tif"), Want::Image), State::Pending | State::Ready(_))); // The project keeps the address; asked to, the file is downloaded whole instead. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("\"in_place\":true") && text.contains("scene.tif")); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "whole": true, "name": "Whole"})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).is_some_and(|l| l.is_raster()), "{r}"); // Opened as a scan, it is downloaded whole and has no coordinates, never read in place. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "as": "scan", "name": "Scan"})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); assert_eq!(r["georeferenced"], false, "{r}"); // A dated address is read in place with its coordinates, so it can't be a scan. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/{{date:YYYY}}.tif"), "as": "scan"})).unwrap_err(); assert!(e.contains("scan"), "{e}"); // Anything else in "as" is refused, not ignored, and tiles can't be a scan. for (url, as_) in [("scene.tif", json!("pdf")), ("scene.tif", json!(3)), ("a.pmtiles", json!("scan")), ("a.fgb", json!("scan"))] { let e = s.run("layer.addUrl", &json!({"url": format!("{base}/{url}"), "as": as_})).unwrap_err(); assert!(e.contains("\"as\"") || e.contains("scan"), "{url}: {e}"); } // A GeoTIFF in strips isn't cloud-optimised: downloaded whole, a raster layer. let r = rustgis_data::raster::Raster::new( "strips", 8, 8, vec![vec![3.0; 64]], rustgis_data::raster::GeoTransform { origin_x: 5.0, origin_y: 50.0, pixel_width: 0.1, pixel_height: -0.1 }, rustgis_data::raster::Crs::LonLat, None, rustgis_data::raster::ColorHint::Gray, "Float32", ) .unwrap(); let (base, _) = serve_ranges(rustgis_data::raster::geotiff::write(&r, 0).unwrap()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/strips.tif")})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).is_some_and(|l| l.is_raster()), "{r}"); // So is one at an address without a file name, its first bytes saying GeoTIFF. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/raster/geotiff?id=1")})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).is_some_and(|l| l.is_raster()), "{r}"); } /// A cloud-optimised GeoTIFF from an API whose address names no file (Vantor's /// `…/ortho-with-overviews/geotiff?…`) is read in place, as one named `.tif` is, not downloaded /// whole; an address the app has probed (`"cog"`) is read in place without the name either. #[test] fn unnamed_cloud_optimised_geotiffs_are_read_in_place() { use crate::cmd::layer::is_unnamed_url; assert!(is_unnamed_url( "https://api.vantor.com/analytics/v1/raster/ortho-with-overviews/geotiff?function=pansharp_ortho&p=crs%3D%22EPSG%3A3857%22" )); assert!(is_unnamed_url("https://x.org/api/tile/")); for named in ["https://x.org/a/scene.tif?sig=1", "https://x.org/a.b/data.geojson", "https://x.org", "https://x.org/", "/tmp/geotiff", "geotiff"] { assert!(!is_unnamed_url(named), "{named}"); } let file = rustgis_data::raster::cog::example(2048, 256); let total = file.len(); let (base, sent) = serve_ranges(file); let url = format!("{base}/analytics/v1/raster/ortho-with-overviews/geotiff?function=pansharp_ortho&p=bands%3D%22red%2Cgreen%2Cblue%22&key=k"); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": url, "name": "Ortho"})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); let l = s.map().layers.iter().find(|l| l.name == "Ortho").unwrap(); let rl = l.raster.as_ref().unwrap(); assert!(rl.in_place && rl.path.as_deref() == Some(url.as_str()), "{:?}", rl.path); let read = sent.load(std::sync::atomic::Ordering::Relaxed); assert!(read < total / 4, "{read} of {total} bytes read: only the header, not the whole file"); let r = s.run("layer.addUrl", &json!({"url": url, "name": "Probed", "cog": true})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); // `"cog"` holds whatever the name says: a GeoTIFF at a FlatGeobuf's name is read as one, // where without it the FlatGeobuf reader refuses it. let fgb = format!("{base}/scene.fgb"); assert!(s.run("layer.addUrl", &json!({"url": fgb, "name": "AsFgb"})).is_err()); let r = s.run("layer.addUrl", &json!({"url": fgb, "name": "Flagged", "cog": true})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); // Asked to, it is downloaded whole, named by its contents. let r = s.run("layer.addUrl", &json!({"url": url, "whole": true, "name": "Whole"})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); // Anything else at such an address is downloaded as before, and said not to be data. let (text, _) = serve_file(b"Unauthorized".to_vec(), false); let e = s.run("layer.addUrl", &json!({"url": format!("{text}/api/geotiff?key=bad")})).unwrap_err(); assert!(e.contains("not a data format"), "{e}"); } /// A cloud-optimised GeoTIFF read in place is a raster layer like a file's: its first look is what /// its PhotometricInterpretation says (RGB in colour, a palette by its table, grey (MinIsBlack, as /// most remote sensing images are) as its first band, but three grey bands as red, green and /// blue), every band there for the raster commands and the Symbology pane to combine and stretch. #[test] fn cog_layers_are_rasters_styled_as_their_photometric_interpretation_says() { use rustgis_data::raster::cog::{Example, example_with}; use rustgis_map::raster::{RasterRenderer, Stretch}; let e = Example { size: 512, tile: 256, ..Example::default() }; let rgb = RasterRenderer::Rgb { red: 0, green: 1, blue: 2 }; let cases: [(&str, Example, usize, Option); 7] = [ ("Pan", Example { bands: 1, ..e }, 1, None), ("PanAlpha", Example { bands: 2, alpha: true, ..e }, 2, None), ("Grey3", Example { bands: 3, ..e }, 3, Some(rgb.clone())), ("Multispectral", Example { bands: 8, ..e }, 8, None), ("Rgb", Example { bands: 3, photometric: 2, ..e }, 3, Some(rgb.clone())), ("Rgbn", Example { bands: 4, photometric: 2, ..e }, 4, Some(rgb.clone())), ("RgbaNir", Example { bands: 5, photometric: 2, alpha: true, planar: true, ..e }, 5, Some(rgb.clone())), ]; let mut s = Session::new(Project::default()); for (name, ex, bands, renderer) in cases { let (base, _) = serve_ranges(example_with(&ex)); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/{name}.tif"), "name": name})).unwrap(); assert_eq!(r["inPlace"], true, "{name}: {r}"); let id = r["layer"].as_u64().unwrap() as u32; let rl = s.map().layer(id).and_then(|l| l.raster.clone()).unwrap(); assert!(rl.in_place, "{name}"); assert_eq!(rl.data.as_ref().map(|d| d.band_count()), Some(bands), "{name}"); match renderer { Some(want) => assert_eq!(rl.style.renderer, want, "{name}"), None => assert!(matches!(rl.style.renderer, RasterRenderer::Stretch { band: 0, .. }), "{name}: {:?}", rl.style.renderer), } // The raster commands combine and stretch its bands, as a file's. if bands >= 3 { let last = bands as u64; s.run("raster.bands", &json!({"layer": name, "red": last, "green": 2, "blue": 1})).unwrap(); let st = s.map().layer(id).and_then(|l| l.raster.as_ref()).map(|r| r.style.clone()).unwrap(); assert_eq!(st.renderer, RasterRenderer::Rgb { red: bands - 1, green: 1, blue: 0 }, "{name}"); } s.run("raster.stretch", &json!({"layer": name, "type": "standardDeviation", "n": 2})).unwrap(); let st = s.map().layer(id).and_then(|l| l.raster.as_ref()).map(|r| r.style.clone()).unwrap(); assert_eq!(st.stretch, Stretch::StandardDeviation { n: 2.0 }, "{name}"); } assert!(s.map().tiles.is_empty(), "no tile layers"); // A palette image, by its table. let (base, _) = serve_ranges(example_with(&Example { classes: true, ..e })); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/classes.tif")})).unwrap(); let rl = s.map().layer(r["layer"].as_u64().unwrap() as u32).and_then(|l| l.raster.clone()).unwrap(); assert_eq!(rl.style.renderer, RasterRenderer::Colormap); // Saved and opened again, it is read in place again, its style kept. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let ms = t.map().layers.iter().find(|l| l.name == "Multispectral").and_then(|l| l.raster.clone()).unwrap(); assert!(ms.in_place && ms.data.is_some() && ms.style.stretch == Stretch::StandardDeviation { n: 2.0 }); } /// A view of a GeoTIFF read in place zoomed in past its overview shows the level zoomed from while /// its own tiles are on their way: no holes, no blocks of the overview, then its own pixels. #[test] fn cog_windows_stand_in_the_level_kept_until_their_tiles_arrive() { use crate::render::rustgis_tiles::cog::raster::window; use std::sync::atomic::{AtomicBool, Ordering}; let gate = std::sync::Arc::new(AtomicBool::new(true)); let (base, _) = serve_gated(rustgis_data::raster::cog::example(2048, 256), true, Some(gate.clone())); let url = format!("{base}/scene.tif"); let mut s = Session::new(Project::default()); // Its overview: level 1 (1024 pixels a side), whose tiles are kept. s.run("layer.addUrl", &json!({"url": url})).unwrap(); // Level 0's tiles held back: a window of it is whole, from level 1, and still to come. gate.store(false, Ordering::Relaxed); let (cols, rows) = ((1000, 1100), (900, 960)); let gray = rustgis_data::raster::ColorHint::Gray; let w = window(&url, 0, cols, rows, &[0], gray.clone()).unwrap(); let part = w.raster.unwrap(); assert!(w.pending); let v = |r: &rustgis_data::raster::Raster, x: u32, y: u32| r.bands[0].data[((y - rows.0) * (cols.1 - cols.0) + (x - cols.0)) as usize]; assert!(part.bands[0].data.iter().all(|v| v.is_finite()), "no holes"); // Level 1's pixel under (1001, 901) is (500, 450): full pixel (1000, 900), (1000 + 900) % 256. assert_eq!(v(&part, 1001, 901), ((1000 + 900) % 256) as f32); // Its own tiles arrive: its own pixels, (x + y) % 256. gate.store(true, Ordering::Relaxed); let mut done = None; for _ in 0..200 { let w = window(&url, 0, cols, rows, &[0], gray.clone()).unwrap(); if !w.pending { done = w.raster; break; } crate::render::rustgis_tiles::cache::wait(std::time::Duration::from_millis(50)); } let part = done.expect("its tiles never came"); assert_eq!(v(&part, 1001, 901), ((1001 + 901) % 256) as f32); } /// A tile a sparse file leaves out (GDAL's: no bytes) is empty, not waited for: a window over it /// is whole at once and asks for nothing more, and an export of the view over it is written rather /// than refused after waiting for the tile. #[test] fn a_tile_a_sparse_file_leaves_out_is_empty_not_waited_for() { use crate::render::rustgis_tiles::cog::raster::window; use rustgis_data::raster::cog::{Example, example_with}; use std::sync::atomic::Ordering; let (base, sent) = serve_ranges(example_with(&Example { size: 2048, tile: 256, sparse: true, ..Example::default() })); let url = format!("{base}/scene.tif"); let mut s = Session::new(Project::default()); // Its overview is level 1 (1024 pixels a side); level 0's first tile has no bytes. let r = s.run("layer.addUrl", &json!({"url": url.clone(), "name": "Scene"})).unwrap(); assert_eq!(r["width"], 1024, "{r}"); let before = sent.load(Ordering::Relaxed); let gray = rustgis_data::raster::ColorHint::Gray; for _ in 0..2 { let w = window(&url, 0, (0, 64), (0, 64), &[0], gray.clone()).unwrap(); assert!(!w.pending, "an empty tile isn't waited for"); assert!(w.raster.unwrap().bands[0].data.iter().all(|v| v.is_nan()), "empty"); } assert_eq!(sent.load(Ordering::Relaxed), before, "nothing is fetched for it"); // The view over it (the image's north-west corner), zoomed in past the overview: written. s.view_size = (64.0, 64.0); s.zoom_to(&rustgis_geom::Extent::new(10.02, 45.92, 10.08, 45.98)); s.map_mut().camera.res = 12.0; let png = std::env::temp_dir().join(format!("rustgis-sparse-{}.png", std::process::id())); s.run("share.exportMap", &json!({"path": png.to_string_lossy(), "width": 64, "height": 64})).unwrap(); assert!(png.exists()); let _ = std::fs::remove_file(png); } /// A GeoTIFF whose server refuses its header with a 429 or 503 and a `Retry-After` fails to be /// added, saying when to ask again, and is read again on a draw once the wait is over. (That it /// isn't asked for before then, and that the map is asked to draw again by then, so the layer /// doesn't stay failed until something else draws it: `rustgis_tiles::cog`'s /// `a_header_refused_with_retry_after_has_the_map_drawn_again_then`, where the gate's state and /// the alarm can be read; here another test may empty the cache meanwhile.) #[test] fn a_header_refused_with_retry_after_is_read_again_when_it_says() { use crate::render::rustgis_tiles::{cache, cog}; use std::sync::atomic::Ordering; use std::time::Duration; // Refused once (busy: ask again in a second), answered after. let (base, sent) = serve_refusing_first(rustgis_data::raster::cog::example(512, 256), 1); let url = format!("{base}/scene.tif"); let mut s = Session::new(Project::default()); let e = s.run("layer.addUrl", &json!({"url": url.clone(), "name": "Scene"})).unwrap_err(); assert!(e.contains("retry after 1 s"), "{e}"); assert_eq!(sent.load(Ordering::Relaxed), 0, "refused: nothing sent"); // Its wait over, it is read again on a draw, and answered. let start = std::time::Instant::now(); let mut read = None; while read.is_none() && start.elapsed() < Duration::from_secs(10) { let _ = cache::generation(); if let Ok(Some(info)) = cog::info(&url) { read = Some(info); } cache::wait(Duration::from_millis(50)); } let width = read.as_ref().and_then(|i| i.layout.levels.first()).map(|l| l.width); assert_eq!(width, Some(512), "not read again once its wait was over"); assert!(sent.load(Ordering::Relaxed) > 0); } /// A GeoTIFF's address look (`#bands=…&range=…`) is its layer's once its pixels land, read later /// (as the browser and an import read them) as when read at once; a palette image stretched by a /// range is drawn in grey, as its tile layer was, and without a look in its colours. #[test] fn an_address_look_holds_when_the_pixels_land_later() { use rustgis_data::raster::cog::{Example, example_with}; use rustgis_map::raster::{AddressLook, RasterLayer, RasterRenderer, Stretch, read_together}; // A session installs the reader. let _s = Session::new(Project::default()); let (base, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 8, ..Example::default() })); let look = AddressLook { bands: vec![4, 2, 1], range: Some((10.0, 200.0)) }; let mut rl = RasterLayer::unread_in_place(format!("{base}/scene.tif")).with_look(look); assert!(rl.data.is_none()); assert!(read_together(&mut [&mut rl], true).is_empty()); assert!(rl.data.is_some()); assert_eq!(rl.style.renderer, RasterRenderer::Rgb { red: 4, green: 2, blue: 1 }); assert_eq!(rl.style.stretch, Stretch::custom(10.0, 200.0)); let (classes, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, ..Example::default() })); let mut stretched = RasterLayer::unread_in_place(format!("{classes}/classes.tif")).with_look(AddressLook { bands: vec![], range: Some((0.0, 100.0)) }); let mut own = RasterLayer::unread_in_place(format!("{classes}/classes.tif")); assert!(read_together(&mut [&mut stretched, &mut own], true).is_empty()); assert!(matches!(stretched.style.renderer, RasterRenderer::Stretch { band: 0, .. }), "{:?}", stretched.style.renderer); assert_eq!(own.style.renderer, RasterRenderer::Colormap); // Saved before its pixels landed (the browser, a read that failed), a layer opens in its look, // or its own one, still. let mut s = Session::new(Project::default()); let look = AddressLook { bands: vec![4, 2, 1], range: Some((10.0, 200.0)) }; s.map_mut().add_raster("Scene".to_string(), RasterLayer::unread_in_place(format!("{base}/scene.tif")).with_look(look)); s.map_mut().add_raster("Classes".to_string(), RasterLayer::unread_in_place(format!("{classes}/classes.tif"))); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let style = |name: &str| s.map().layers.iter().find(|l| l.name == name).and_then(|l| l.raster.as_ref()).map(|r| r.style.clone()).unwrap(); assert_eq!(style("Scene").renderer, RasterRenderer::Rgb { red: 4, green: 2, blue: 1 }); assert_eq!(style("Scene").stretch, Stretch::custom(10.0, 200.0)); assert_eq!(style("Classes").renderer, RasterRenderer::Colormap); } /// An in-place address a project keeps is checked as a tile layer's is before anything is asked /// for: a hand-edited one that isn't http(s) or s3 is refused, and said, on opening and on adding. #[test] fn an_unchecked_in_place_address_is_refused() { use rustgis_map::raster::RasterLayer; let bad = "ftp://127.0.0.1/scene.tif"; let mut s = Session::new(Project::default()); s.map_mut().add_raster("Odd".to_string(), RasterLayer::unread_in_place(bad)); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let rl = s.map().layers.iter().find(|l| l.name == "Odd").and_then(|l| l.raster.as_ref()).unwrap(); assert!(rl.data.is_none() && rl.error.as_deref().is_some_and(|e| e.contains("tile addresses start with")), "{:?}", rl.error); assert_eq!(s.notices.iter().filter(|n| n.error && n.text.contains("Odd: couldn't be read in place")).count(), 1); // Added directly (the browser's path), refused before the reader is asked. let added = RasterLayer::opening_in_place(bad); assert!(added.error.is_some() && added.data.is_none()); } /// A PNG export of a raster read in place whose image can't be read (its tiles, past the overview /// it holds) is refused, not written from the overview. #[test] fn a_png_export_of_an_unreadable_in_place_layer_is_refused() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let mut overview = Raster::new( "gone", 4, 4, vec![(0..16).map(|v| v as f32).collect()], GeoTransform { origin_x: -180.0, origin_y: 80.0, pixel_width: 90.0, pixel_height: -40.0 }, Crs::LonLat, None, ColorHint::Gray, "Float32", ) .unwrap(); overview.overview = Some((4096, 4096)); let mut s = Session::new(Project::default()); s.map_mut().add_raster("Gone".to_string(), rustgis_map::raster::RasterLayer::in_place(overview, "http://127.0.0.1:9/gone.tif")); // Zoomed in far past the overview: the image's tiles are needed, and can't be read. s.map_mut().camera.center = rustgis_geom::Coord::new(0.0, 0.0); s.map_mut().camera.res = 25.0; s.view_size = (64.0, 64.0); let png = std::env::temp_dir().join(format!("rustgis-in-place-refused-{}.png", std::process::id())); let e = s.run("share.exportMap", &json!({"path": png.to_string_lossy(), "width": 64, "height": 64})).unwrap_err(); assert!(e.contains("isn't written") || e.contains("still on their way"), "{e}"); assert!(!png.exists()); } /// A turned layer read in place is drawn from its overview: zoomed in past it, an export is /// refused rather than written from the overview; zoomed out, it is written. #[test] fn a_turned_in_place_layer_is_not_exported_past_its_overview() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(2048, 256)); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "name": "Scene"})).unwrap(); assert_eq!(r["width"], 1024, "{r}"); let id = s.map().layer_by_name("Scene").unwrap().id; s.map_mut().layer_mut(id).and_then(|l| l.raster.as_mut()).unwrap().style.rotation = 15.0; let png = std::env::temp_dir().join(format!("rustgis-turned-{}.png", std::process::id())); // Zoomed in past the overview (its pixels are about 50 m): refused. s.map_mut().camera.res = 12.0; s.view_size = (64.0, 64.0); let e = s.run("share.exportMap", &json!({"path": png.to_string_lossy(), "width": 64, "height": 64})).unwrap_err(); assert!(e.contains("turned") && e.contains("isn't written"), "{e}"); // Zoomed out to the overview's resolution and beyond: written. s.map_mut().camera.res = 200.0; s.run("share.exportMap", &json!({"path": png.to_string_lossy(), "width": 64, "height": 64})).unwrap(); assert!(png.exists()); } /// An address look naming bands the file hasn't (`#bands=9,8,7` on a 3-band file), or two bands /// (`#bands=1,2`, which no raster look draws), is said, and the layer draws in its own look; its /// range is kept. #[test] fn a_look_naming_bands_the_file_hasnt_is_said() { use rustgis_data::raster::cog::{Example, example_with}; use rustgis_map::raster::{RasterRenderer, Stretch}; let (base, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 3, ..Example::default() })); let mut s = Session::new(Project::default()); s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif#bands=9,8,7&range=10,200"), "name": "Scene"})).unwrap(); let said = |s: &Session| s.notices.iter().filter(|n| n.text.contains("asks for band(s) 9, 8, 7")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices); let rl = s.map().layer_by_name("Scene").and_then(|l| l.raster.as_ref()).unwrap(); assert_eq!(rl.style.renderer, RasterRenderer::Rgb { red: 0, green: 1, blue: 2 }, "the file's own look: three grey bands as RGB"); assert_eq!(rl.style.stretch, Stretch::custom(10.0, 200.0)); s.run("map.zoomIn", &Value::Null).unwrap(); assert_eq!(said(&s), 1); // Two bands: neither grey nor RGB, said as such. s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif#bands=1,2&range=10,200"), "name": "Pair"})).unwrap(); assert_eq!(s.notices.iter().filter(|n| n.text.contains("Pair") && n.text.contains("asks for 2 bands")).count(), 1, "{:?}", s.notices); let rl = s.map().layer_by_name("Pair").and_then(|l| l.raster.as_ref()).unwrap(); assert_eq!(rl.style.renderer, RasterRenderer::Rgb { red: 0, green: 1, blue: 2 }); assert_eq!(rl.style.stretch, Stretch::custom(10.0, 200.0)); // A palette image: in grey with the range, as its tile layer draws one whose address names // bands (its colours are of one band alone), not in its colours without the range. let (classes, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, ..Example::default() })); s.run("layer.addUrl", &json!({"url": format!("{classes}/classes.tif#bands=9,8,7&range=10,200"), "name": "Classes"})).unwrap(); let said = s .notices .iter() .filter(|n| n.text.contains("Classes") && n.text.contains("asks for band(s) 9, 8, 7") && n.text.contains("drawn in grey")); assert_eq!(said.count(), 1, "{:?}", s.notices); let rl = s.map().layer_by_name("Classes").and_then(|l| l.raster.as_ref()).unwrap(); assert!(matches!(rl.style.renderer, RasterRenderer::Stretch { band: 0, .. }), "{:?}", rl.style.renderer); assert_eq!(rl.style.stretch, Stretch::custom(10.0, 200.0)); } /// A file without overviews whose only image has more tiles than the fetch queue holds (small /// tiles) is read in place all the same: its overview is asked for a batch at a time, and a tile /// the queue dropped is asked for again, so the layer lands rather than failing with "dropped". #[test] fn an_overview_of_more_tiles_than_the_fetch_queue_holds_lands() { // This fixture fills the shared native queue; other tests must keep their own requests. #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::an_overview_of_more_tiles_than_the_fetch_queue_holds_lands") { return; } use rustgis_data::raster::cog::{Example, example_with}; let (base, _) = serve_ranges(example_with(&Example { size: 1024, tile: 32, no_overviews: true, ..Example::default() })); let mut s = Session::new(Project::default()); s.run("layer.addUrl", &json!({"url": format!("{base}/flat.tif"), "name": "Flat"})).unwrap(); let rl = s.map().layer_by_name("Flat").and_then(|l| l.raster.as_ref()).unwrap(); assert!(rl.in_place); let d = rl.data.as_ref().unwrap(); assert_eq!((d.width, d.height), (1024, 1024)); // Every tile, not a stand-in: pixel (x, y) of band 0 is (x + y) % 256. let at = |x: u32, y: u32| d.bands[0].data[(y * d.width + x) as usize]; assert_eq!((at(0, 1), at(1023, 1023), at(500, 700)), (1.0, 254.0, 176.0)); } /// A window over more tiles than the fetch queue holds (small tiles) asks for them a batch at a /// time, the next as the map draws again, rather than all at once (which would push its own first /// requests out of the queue, to ask for them all again at the next draw), and is pending until /// the last lands. The batch bound and final pixels are checked; the total fetch count may vary /// because dropped requests can be tried again. #[test] fn a_window_of_more_tiles_than_the_fetch_queue_holds_asks_for_them_in_batches() { // This fixture fills the shared native queue; other tests must keep their own requests. #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::a_window_of_more_tiles_than_the_fetch_queue_holds_asks_for_them_in_batches") { return; } use crate::render::rustgis_tiles::{cache, cog}; use rustgis_data::raster::ColorHint; use rustgis_data::raster::cog::{Example, example_with}; use std::time::Duration; let _s = Session::new(Project::default()); let (base, _) = serve_ranges(example_with(&Example { size: 1024, tile: 32, no_overviews: true, ..Example::default() })); let url = format!("{base}/flat.tif"); // The header first; then the window asks for its first batch. let start = std::time::Instant::now(); let mut w = cog::raster::window(&url, 0, (0, 1024), (0, 1024), &[0], ColorHint::Gray).unwrap(); while w.raster.is_none() && start.elapsed() < Duration::from_secs(20) { cache::wait(Duration::from_millis(20)); w = cog::raster::window(&url, 0, (0, 1024), (0, 1024), &[0], ColorHint::Gray).unwrap(); } assert!(w.raster.is_some() && w.pending && !w.failed); let asked = cog::raster::asked(&url, 0); assert!(asked <= 256, "{asked} asked for at once"); // Drawn again as tiles land, the window asks for the rest, and is whole in the end. while w.pending && start.elapsed() < Duration::from_secs(60) { cache::wait(Duration::from_millis(20)); w = cog::raster::window(&url, 0, (0, 1024), (0, 1024), &[0], ColorHint::Gray).unwrap(); } assert!(!w.pending && !w.failed, "still pending after {:?}", start.elapsed()); let r = w.raster.unwrap(); let at = |x: u32, y: u32| r.bands[0].data[(y * r.width + x) as usize]; assert_eq!((at(0, 1), at(1023, 1023), at(500, 700)), (1.0, 254.0, 176.0)); assert!(r.bands[0].data.iter().all(|v| v.is_finite()), "a gap"); } /// A palette image's overview without a colour table under a grey full image is read as the full /// image is, in grey, and nothing is said: a file is what its full image says (as `main` reads a /// grey full image with a stray colour table as grey), a window over it is read the same way, and /// the overview's own say is the one thing the reader doesn't take over the full image's. #[test] fn a_palette_overview_under_a_grey_full_image_is_read_grey_as_the_full_image_is() { use rustgis_data::raster::cog::{Example, example_with}; use rustgis_map::raster::RasterRenderer; // 4096 × 4096 in 256 px tiles: the overview held is the 1024 × 1024 one (a palette image // without a table), not the full image (grey, 16 M samples). let file = example_with(&Example { size: 4096, tile: 256, classes: true, stray: true, no_table: true, ..Example::default() }); let (base, _) = serve_ranges(file); let mut s = Session::new(Project::default()); s.run("layer.addUrl", &json!({"url": format!("{base}/stray.tif"), "name": "Stray"})).unwrap(); let rl = s.map().layer_by_name("Stray").and_then(|l| l.raster.as_ref()).unwrap(); let d = rl.data.as_ref().unwrap(); assert_eq!((d.width, d.overview), (1024, Some((4096, 4096))), "the overview held"); assert!(matches!(rl.style.renderer, RasterRenderer::Stretch { band: 0, .. }), "{:?}", rl.style.renderer); assert!(!s.notices.iter().any(|n| n.text.contains("colour table")), "{:?}", s.notices); } /// A layer read in place whose overview couldn't be read (its server refused the file's header, /// saying when to ask again) is read again once its wait is over, as the app draws, and lands then: /// not before, and failing was said once. #[test] fn an_in_place_layers_failed_overview_is_read_again_when_its_wait_is_over() { use rustgis_map::raster::RasterLayer; use std::sync::atomic::Ordering; use std::time::Duration; let (base, sent) = serve_refusing_first(rustgis_data::raster::cog::example(512, 256), 2); let mut s = Session::new(Project::default()); s.map_mut().add_raster("Scene".to_string(), RasterLayer::unread_in_place(format!("{base}/scene.tif"))); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let layer = |s: &Session| s.map().layer_by_name("Scene").and_then(|l| l.raster.clone()).unwrap(); let rl = layer(&s); assert!(rl.data.is_none()); assert!(rl.error.as_deref().is_some_and(|e| e.contains("retry after 1 s")), "{:?}", rl.error); assert_eq!(rl.read_failure().map(|(_, _, failures)| failures), Some(1)); let said = |s: &Session| s.notices.iter().filter(|n| n.error && n.text.contains("Scene: couldn't be read in place")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices); // Not before its wait is over. s.read_in_place_again(); assert!(layer(&s).data.is_none()); assert_eq!(sent.load(Ordering::Relaxed), 0, "read again before its wait was over"); // Nor by a repair of the project (a layer file added, maps imported), which reads the layers // not read yet together: a failed read is paced, not started over. let at = layer(&s).read_failure().map(|(_, at, _)| at).unwrap(); let mut other = rustgis_map::Map::new("Other"); let id = other.add_dataset(crate::data::Dataset::new("Empty", crate::data::GeometryType::Point, Vec::new())); crate::cmd::layer::add_layer_text(&mut s, &serde_json::to_string(other.layer(id).unwrap()).unwrap()).unwrap(); assert_eq!( layer(&s).read_failure().map(|(_, at, failures)| (at, failures)), Some((at, 1)), "started over by the repair: {:?}", layer(&s).error ); assert_eq!(sent.load(Ordering::Relaxed), 0); // Then read again, as the map draws: refused again, it waits twice as long this time (see // `cache::backoff`), and isn't said again. let view = rustgis_geom::Extent::new(10.0, 45.9, 10.1, 46.0); let draw = |s: &mut Session| { crate::render::rustgis_tiles::cache::wait(Duration::from_millis(50)); s.read_in_place_again(); s.map_mut().sync_sources(&view, 1000.0, false); s.notice_landed_rasters(); }; let start = std::time::Instant::now(); while layer(&s).read_failure().is_none_or(|(_, _, failures)| failures < 2) && start.elapsed() < Duration::from_secs(20) { draw(&mut s); } let rl = layer(&s); let (_, at, failures) = rl.read_failure().expect("refused again"); assert_eq!(failures, 2); assert_eq!(sent.load(Ordering::Relaxed), 0); assert_eq!(said(&s), 1, "{:?}", s.notices); // Not before twice its wait is over (looked at while that is still ahead). if at.elapsed() < Duration::from_millis(1800) { s.read_in_place_again(); assert!(layer(&s).read_failure().is_some(), "read again before twice its wait was over"); } // Then read a third time, landing as the map draws. let start = std::time::Instant::now(); while layer(&s).data.is_none() && start.elapsed() < Duration::from_secs(20) { draw(&mut s); } let rl = layer(&s); assert!(rl.data.is_some(), "{:?}", rl.error); assert!(rl.error.is_none() && rl.read_failure().is_none()); assert!(sent.load(Ordering::Relaxed) > 0); assert_eq!(said(&s), 1, "{:?}", s.notices); } /// A cloud-optimised GeoTIFF added without waiting before its header is in (the browser, where /// nothing may wait, so an address isn't probed first as it is natively) answers that it is still /// being read, with no size yet rather than none; the size comes with the header (see /// `a_cog_added_without_waiting_lands_as_the_map_draws`). #[test] fn a_cog_added_before_its_header_is_in_says_it_is_being_read() { use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; let gate = std::sync::Arc::new(AtomicBool::new(false)); let (base, _) = serve_gated(rustgis_data::raster::cog::example(512, 256), true, Some(gate.clone())); let url = format!("{base}/scene.tif"); let mut s = Session::new(Project::default()); s.in_place_wait = false; // As the browser adds it: straight in place, its header read with its overview. let r = crate::cmd::layer::add_cog(&mut s, &json!({"name": "Scene"}), &url, false).unwrap(); assert_eq!((r["inPlace"].clone(), r["reading"].clone()), (json!(true), json!(true)), "{r}"); assert!(r["width"].is_null() && r["height"].is_null() && r["bands"].is_null(), "sized before its header is in: {r}"); assert!(r.get("bounds").is_none(), "{r}"); // Its header and overview arrive as the map draws. gate.store(true, Ordering::Relaxed); let layer = |s: &Session| s.map().layer_by_name("Scene").and_then(|l| l.raster.clone()).unwrap(); let view = rustgis_geom::Extent::new(10.0, 45.0, 11.0, 46.0); let start = std::time::Instant::now(); while layer(&s).data.is_none() && start.elapsed() < Duration::from_secs(20) { crate::render::rustgis_tiles::cache::wait(Duration::from_millis(50)); s.map_mut().sync_sources(&view, 1000.0, false); s.notice_landed_rasters(); } let rl = layer(&s); assert_eq!(rl.data.as_ref().map(|d| (d.width, d.band_count())), Some((512, 1)), "{:?}", rl.error); } /// A raster layer read in place whose read can't start (a project edited by hand keeps an address /// that can't be read) says so once, and a repair of the project (a layer file added, maps /// imported) leaves it as it is: not started over, not said again. #[test] fn a_read_in_place_that_couldnt_start_isnt_started_over_by_a_repair() { use rustgis_map::raster::RasterLayer; let mut s = Session::new(Project::default()); s.map_mut().add_raster("Scene".to_string(), RasterLayer::unread_in_place("ftp://files.example.org/scene.tif")); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let layer = |s: &Session| s.project.maps[0].layer_by_name("Scene").and_then(|l| l.raster.clone()).unwrap(); let rl = layer(&s); assert!(rl.data.is_none() && rl.read_failure().is_none(), "{rl:?}"); let error = rl.error.clone().expect("couldn't start"); assert!(error.contains("start with https://"), "{error}"); let said = |s: &Session| s.notices.iter().filter(|n| n.error && n.text.contains("Scene: couldn't be read in place")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices); let mut other = rustgis_map::Map::new("Other"); let id = other.add_dataset(crate::data::Dataset::new("Empty", crate::data::GeometryType::Point, Vec::new())); crate::cmd::layer::add_layer_text(&mut s, &serde_json::to_string(other.layer(id).unwrap()).unwrap()).unwrap(); s.run("map.import", &json!({"text": serde_json::to_string(&rustgis_map::Map::new("Other")).unwrap()})).unwrap(); s.notice_landed_rasters(); let rl = layer(&s); assert_eq!((rl.error.as_deref(), rl.read_failure().is_none()), (Some(error.as_str()), true), "started over: {rl:?}"); assert_eq!(said(&s), 1, "said again: {:?}", s.notices); } /// Where the session waits for reads in place (the command line, Python), a failed overview is /// read again by the next command run once its wait is over, and waited for: a long-lived session /// that doesn't draw has nothing else to ask again. #[test] fn a_waiting_sessions_next_command_reads_a_failed_overview_again() { use std::sync::atomic::Ordering; use std::time::Duration; let (base, sent) = serve_refusing_first(rustgis_data::raster::cog::example(512, 256), 1); let mut s = Session::new(Project::default()); s.map_mut().add_raster("Scene".to_string(), rustgis_map::raster::RasterLayer::unread_in_place(format!("{base}/scene.tif"))); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let layer = |s: &Session| s.map().layer_by_name("Scene").and_then(|l| l.raster.clone()).unwrap(); assert_eq!(layer(&s).read_failure().map(|(_, _, failures)| failures), Some(1), "{:?}", layer(&s).error); let said = |s: &Session| s.notices.iter().filter(|n| n.error && n.text.contains("Scene: couldn't be read in place")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices); // A command run before its wait is over doesn't read it. s.run("info.project", &json!({})).unwrap(); assert!(layer(&s).data.is_none() && layer(&s).read_failure().is_some()); assert_eq!(sent.load(Ordering::Relaxed), 0, "read again before its wait was over"); // One run after does, waiting for it: the layer holds its overview as the command answers. std::thread::sleep(Duration::from_millis(1100)); s.run("info.project", &json!({})).unwrap(); let rl = layer(&s); assert!(rl.data.is_some(), "{:?}", rl.error); assert!(rl.error.is_none() && rl.read_failure().is_none()); assert!(sent.load(Ordering::Relaxed) > 0); assert_eq!(said(&s), 1, "{:?}", s.notices); } /// A retry belongs to the read which failed, not whichever command happens to run once /// its server's wait is over. A native read still recovers when the native user asks again. #[test] fn permission_policy_raster_retry_keeps_the_original_owner() { use crate::policy::{AgentAccess, Effects, Revocation, Scope}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering::SeqCst}, }; use std::time::Duration; struct Live; impl Revocation for Live { fn allows(&self, _: Effects) -> bool { true } } let mut failures = Vec::new(); for (owned, restricted) in [(true, false), (false, true), (false, false)] { let (base, sent) = serve_refusing_first(rustgis_data::raster::cog::example(512, 128), 1); let mut s = Session::new(Project::default()); let live = Arc::new(AtomicBool::new(true)); let flag = live.clone(); let still = owned.then(|| Arc::new(move || flag.load(SeqCst)) as crate::render::rustgis_tiles::cache::Still); let mut raster = rustgis_map::raster::RasterLayer::unread_in_place(format!("{base}/scene.tif")); crate::render::rustgis_tiles::cache::with_optional_still(still, || assert!(raster.reload().is_err())); assert_eq!(raster.read_failure().map(|(_, _, n)| n), Some(1)); s.map_mut().add_raster("Scene", raster); live.store(false, SeqCst); std::thread::sleep(Duration::from_millis(1100)); if restricted { let scope = Scope::agent(AgentAccess::Refuse, true, "raster-retry", None, Arc::new(Live)); s.with_scope(scope, |s| s.run("info.project", &json!({}))).unwrap().unwrap(); } else { s.run("info.project", &json!({})).unwrap(); } let landed = s.map().layer_by_name("Scene").unwrap().raster.as_ref().unwrap().data.is_some(); if owned || restricted { if landed || sent.load(SeqCst) != 0 { failures.push(format!("retry escaped its owner: owned={owned}, restricted={restricted}")); } } else { assert!(landed && sent.load(SeqCst) > 0, "native maintenance must keep recovering"); } } assert!(failures.is_empty(), "{failures:?}"); } /// Where the session doesn't wait for reads in place (the desktop app, whose commands run on the /// window's own thread), a cloud-optimised GeoTIFF is added at once, its header probed as the app /// does, and its overview lands as the map draws; one that can't be read is added too, and says so /// once its read fails. A project opens the same way. Waiting, the command fails instead. #[test] fn a_cog_added_without_waiting_lands_as_the_map_draws() { use std::time::Duration; let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let url = format!("{base}/scene.tif"); let mut s = Session::new(Project::default()); s.in_place_wait = false; let _ = crate::render::rustgis_tiles::cog::probe_blocking(&url); let r = s.run("layer.addUrl", &json!({"url": url, "name": "Scene"})).unwrap(); assert_eq!((r["inPlace"].as_bool(), r["kind"].as_str()), (Some(true), Some("raster")), "{r}"); assert_eq!(r["bounds"], json!([10.0, 45.0, 11.0, 46.0]), "framed on the header read: {r}"); // Sized by the header read, as the answer is once the overview has landed (512 a side: held // whole, so not an overview of anything). assert_eq!( (r["width"].clone(), r["height"].clone(), r["bands"].clone(), r["reading"].clone(), r.get("overviewOf")), (json!(512), json!(512), json!(1), json!(true), None), "{r}" ); let layer = |s: &Session, name: &str| s.map().layer_by_name(name).and_then(|l| l.raster.clone()).unwrap(); let rl = layer(&s, "Scene"); assert!(rl.in_place && rl.data.is_none() && rl.error.is_none(), "added before its overview is in: {rl:?}"); let view = rustgis_geom::Extent::new(10.0, 45.0, 11.0, 46.0); let draw = |s: &mut Session| { crate::render::rustgis_tiles::cache::wait(Duration::from_millis(50)); s.read_in_place_again(); s.map_mut().sync_sources(&view, 1000.0, false); s.notice_landed_rasters(); }; let start = std::time::Instant::now(); while layer(&s, "Scene").data.is_none() && start.elapsed() < Duration::from_secs(20) { draw(&mut s); } let rl = layer(&s, "Scene"); assert_eq!(rl.data.as_ref().map(|d| (d.width, d.height)), Some((512, 512)), "{:?}", rl.error); // One whose header reads (the first request) but whose tiles can't be is added, and says so // once its read fails. let (broken, _) = serve_refusing_from(rustgis_data::raster::cog::example(512, 256), 2); let broken = format!("{broken}/broken.tif"); s.run("layer.addUrl", &json!({"url": broken, "name": "Broken"})).unwrap(); let start = std::time::Instant::now(); while layer(&s, "Broken").error.is_none() && start.elapsed() < Duration::from_secs(20) { draw(&mut s); } assert!(layer(&s, "Broken").error.as_deref().is_some_and(|e| e.contains("500")), "{:?}", layer(&s, "Broken").error); let said = |s: &Session| s.notices.iter().filter(|n| n.error && n.text.contains("Broken: couldn't be read in place")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices); // Waiting, the same address fails the command. s.in_place_wait = true; assert!(s.run("layer.addUrl", &json!({"url": broken, "name": "Again"})).unwrap_err().contains("500")); // A project opens the same way: its layers read in place land as the map draws. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.in_place_wait = false; t.run("project.openText", &json!({"text": text})).unwrap(); assert!(layer(&t, "Scene").data.is_none(), "opened before its overview is in"); let start = std::time::Instant::now(); while layer(&t, "Scene").data.is_none() && start.elapsed() < Duration::from_secs(20) { draw(&mut t); } assert!(layer(&t, "Scene").data.is_some(), "{:?}", layer(&t, "Scene").error); } /// Binding a saved S3 connection to a project's rasters read in place reads their overviews /// together, in the time of the slowest (see `s3_connections::reopen_where`), not one after the /// other: the stand-in bucket answers nothing until both files have been asked for, so reads made /// one at a time would never get their first answer. #[test] fn binding_a_connection_reads_its_in_place_rasters_together() { // The fixture changes process-wide S3 accounts; keep parallel readers in their own store. if isolate_process_test("tests::binding_a_connection_reads_its_in_place_rasters_together") { return; } use rustgis_map::raster::RasterLayer; use std::time::Duration; let base = serve_s3_together(vec![ ("a.tif", rustgis_data::raster::cog::example(512, 256)), ("b.tif", rustgis_data::raster::cog::example(256, 512)), ]); let mut s = Session::new(Project::default()); s.map_mut().add_raster("A".to_string(), RasterLayer::unread_in_place("s3://s3-together-bucket/a.tif")); s.map_mut().add_raster("B".to_string(), RasterLayer::unread_in_place("s3://s3-together-bucket/b.tif")); let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run( "connection.new", &json!({"kind": "s3", "name": "Together", "address": base, "options": {"credentials": "none", "buckets": "s3-together-bucket"}}), ) .unwrap(); let start = std::time::Instant::now(); t.run("project.openText", &json!({"text": text})).unwrap(); for (name, width) in [("A", 512), ("B", 256)] { let rl = t.map().layer_by_name(name).and_then(|l| l.raster.clone()).unwrap(); assert_eq!(rl.data.as_ref().map(|d| d.width), Some(width), "{name}: {:?}", rl.error); } assert!(start.elapsed() < Duration::from_secs(60), "read one after the other: {:?}", start.elapsed()); t.run("connection.remove", &json!({"id": "Together"})).unwrap(); } /// Where the session doesn't wait for reads in place (the desktop), a saved S3 connection chosen /// for a project's rasters read in place starts their overviews reading and says so: they count /// as `reading` in `connection.resolveS3`'s answer, not as `reopened`, until they land as the map /// draws. Waiting, the same choice reads them before it answers, and they are reopened. #[test] fn a_connection_chosen_without_waiting_counts_its_in_place_rasters_as_reading() { // The fixture changes process-wide S3 accounts; keep parallel readers in their own store. if isolate_process_test("tests::a_connection_chosen_without_waiting_counts_its_in_place_rasters_as_reading") { return; } use rustgis_map::raster::RasterLayer; use std::time::Duration; let base = serve_s3_together(vec![ ("a.tif", rustgis_data::raster::cog::example(512, 256)), ("b.tif", rustgis_data::raster::cog::example(256, 512)), ]); let mut s = Session::new(Project::default()); s.map_mut().add_raster("A".to_string(), RasterLayer::unread_in_place("s3://s3-chosen-bucket/a.tif")); s.map_mut().add_raster("B".to_string(), RasterLayer::unread_in_place("s3://s3-chosen-bucket/b.tif")); let text = serde_json::to_string(&s.project).unwrap(); let layer = |s: &Session, name: &str| s.map().layer_by_name(name).and_then(|l| l.raster.clone()).unwrap(); // The connection lists no bucket, so the project's sources wait for the user's choice of it. let open = |wait: bool| { let mut t = Session::new(Project::default()); t.in_place_wait = wait; let c = t.run("connection.new", &json!({"kind": "s3", "name": "Chosen", "address": base, "options": {"credentials": "none"}})).unwrap(); let id = c["id"].as_str().unwrap_or_default().to_string(); assert!(!id.is_empty(), "{c}"); t.run("project.openText", &json!({"text": text})).unwrap(); let source = t.s3_pending.first().map(|p| p.source.clone()).unwrap_or_default(); assert!(!source.is_empty(), "nothing waited for a choice: {:?}", t.notices); let r = t.run("connection.resolveS3", &json!({"source": source, "id": id})).unwrap(); (t, r, id) }; let counts = |r: &Value| (r["reopened"].as_u64(), r["reading"].as_u64(), r["failed"].as_u64()); let (mut t, r, id) = open(false); assert_eq!(counts(&r), (Some(0), Some(2), Some(0)), "{r}"); assert!(layer(&t, "A").data.is_none() && layer(&t, "B").data.is_none(), "chosen before the overviews are in"); let view = rustgis_geom::Extent::new(10.0, 45.0, 11.0, 46.0); let start = std::time::Instant::now(); while (layer(&t, "A").data.is_none() || layer(&t, "B").data.is_none()) && start.elapsed() < Duration::from_secs(60) { crate::render::rustgis_tiles::cache::wait(Duration::from_millis(50)); t.read_in_place_again(); t.map_mut().sync_sources(&view, 1000.0, false); t.notice_landed_rasters(); } for (name, width) in [("A", 512), ("B", 256)] { assert_eq!(layer(&t, name).data.as_ref().map(|d| d.width), Some(width), "{name}: {:?}", layer(&t, name).error); } t.run("connection.remove", &json!({"id": id})).unwrap(); // Waiting, the choice answers once the overviews are in. let (mut t, r, id) = open(true); assert_eq!(counts(&r), (Some(2), Some(0), Some(0)), "{r}"); assert!(layer(&t, "A").data.is_some() && layer(&t, "B").data.is_some()); t.run("connection.remove", &json!({"id": id})).unwrap(); } /// A stand-in S3 endpoint serving `files` (key, bytes) by range, answering nothing until each key /// has been asked for once: reads made together get their answers, reads made one after the other /// never get the first. fn serve_s3_together(files: Vec<(&'static str, Vec)>) -> String { use std::io::{Read, Write}; use std::time::Duration; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let files = std::sync::Arc::new(files); let asked: std::sync::Arc>> = Default::default(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; let (files, asked) = (files.clone(), asked.clone()); std::thread::spawn(move || { let mut buf = [0u8; 8192]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]).to_ascii_lowercase(); let path = req.split_whitespace().nth(1).unwrap_or("/").split(['?', '#']).next().unwrap_or("/").to_string(); let Some((key, body)) = files.iter().find(|(k, _)| path.ends_with(&format!("/{k}"))) else { let _ = s.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; }; asked.lock().unwrap().insert(key); let start = std::time::Instant::now(); while asked.lock().unwrap().len() < files.len() && start.elapsed() < Duration::from_secs(150) { std::thread::sleep(Duration::from_millis(5)); } if req.starts_with("head ") { let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); return; } let range = req.lines().find_map(|l| l.strip_prefix("range: bytes=")).and_then(|r| { let (a, b) = r.trim().split_once('-')?; if a.is_empty() { let n = b.parse::().ok()?.min(body.len()); return Some((body.len() - n, body.len().saturating_sub(1))); } Some((a.parse::().ok()?, b.parse::().ok()?)) }); let (status, part) = match range { Some((a, b)) if a < body.len() => ("206 Partial Content", &body[a..=b.min(body.len() - 1)]), Some(_) => ("416 Range Not Satisfiable", &body[..0]), None => ("200 OK", &body[..]), }; let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", part.len()); let _ = s.write_all(part); }); } }); base } /// A COG tile layer opened as a raster layer takes a numbered name when another layer has its /// name, as a layer added does. #[test] fn a_migrated_cog_layer_takes_a_free_name() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let mut s = Session::new(Project::default()); let empty = r#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{}}]}"#; s.run("layer.addDataText", &json!({"fileName": "scene.geojson", "text": empty, "name": "Scene"})).unwrap(); assert!(s.map().layer_by_name("Scene").is_some()); let mut text: Value = serde_json::to_value(&s.project).unwrap(); let tile = serde_json::to_value(rustgis_map::TileLayer::new("Scene", rustgis_map::TileKind::Cog, format!("{base}/scene.tif"))).unwrap(); match text.pointer_mut("/maps").and_then(Value::as_array_mut) { Some(maps) => maps[0]["tiles"] = json!([tile]), None => text["map"]["tiles"] = json!([tile]), } let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text.to_string()})).unwrap(); let names: Vec<&str> = t.map().layers.iter().map(|l| l.name.as_str()).collect(); assert!(names.contains(&"Scene") && names.contains(&"Scene (2)"), "{names:?}"); assert!(t.map().layer_by_name("Scene (2)").and_then(|l| l.raster.as_ref()).is_some_and(|r| r.in_place)); } /// A layer read in place that can't be read says so, once, as its project opens. #[test] fn an_in_place_layer_that_cant_be_read_says_so() { let mut s = Session::new(Project::default()); s.map_mut().add_raster("Gone".to_string(), rustgis_map::raster::RasterLayer::unread_in_place("http://127.0.0.1:9/gone.tif")); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); let said = |s: &Session| s.notices.iter().filter(|n| n.error && n.text.contains("Gone: couldn't be read in place")).count(); assert_eq!(said(&s), 1, "{:?}", s.notices.last()); s.run("map.zoomIn", &Value::Null).unwrap(); assert_eq!(said(&s), 1); } /// A project an older RustGIS saved again (it didn't know `in_place`: the layer was missing there, /// and saved without the key) reads its cloud-optimised GeoTIFF in place again: a raster layer /// whose path is a plain web address is read no other way. #[test] fn a_project_an_older_rustgis_saved_again_reads_its_cog_in_place() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let mut s = Session::new(Project::default()); s.map_mut().add_raster("Scene".to_string(), rustgis_map::raster::RasterLayer::unread_in_place(format!("{base}/scene.tif"))); let mut text: Value = serde_json::to_value(&s.project).unwrap(); // As the older RustGIS saved it: the path, without `in_place`. let raster = text.pointer_mut("/maps/0/layers/0/raster").and_then(Value::as_object_mut).unwrap(); assert_eq!(raster.remove("in_place"), Some(json!(true))); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text.to_string()})).unwrap(); let rl = s.map().layer_by_name("Scene").and_then(|l| l.raster.clone()).unwrap(); assert!(rl.in_place && rl.error.is_none(), "{:?}", rl.error); assert_eq!(rl.data.as_ref().map(|d| d.width), Some(512)); } /// A palette COG without its colour table is drawn in grey and says so, also when its pixels land /// after the layer is added (a project opened, as in the browser and an import): once, and not for /// one with its table. #[test] fn a_palette_without_its_table_says_so_when_its_pixels_land() { use rustgis_data::raster::cog::{Example, example_with}; let clean = example_with(&Example { size: 300, tile: 128, classes: true, ..Example::default() }); let lossy = example_with(&Example { size: 300, tile: 128, classes: true, no_table: true, ..Example::default() }); let said = |s: &Session| s.notices.iter().filter(|n| n.text.contains("no colour table")).count(); for (file, want) in [(lossy, 1), (clean, 0)] { let (base, _) = serve_ranges(file); let mut s = Session::new(Project::default()); let layer = rustgis_map::raster::RasterLayer::unread_in_place(format!("{base}/classes.tif")); s.map_mut().add_raster("Classes".to_string(), layer); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); assert!(s.map().layers.iter().any(|l| l.raster.as_ref().is_some_and(|r| r.data.is_some())), "read on opening"); assert_eq!(said(&s), want, "{:?}", s.notices.iter().map(|n| n.text.as_str()).collect::>()); // Said once for these pixels, however often asked. s.notice_landed_rasters(); s.run("map.zoomIn", &Value::Null).unwrap(); assert_eq!(said(&s), want); } } /// A data cube's file (`"cube": true`) is a tile layer, and stays one when its project opens /// again: its address keeps the marker, which the migration respects. #[test] fn a_data_cubes_file_stays_a_tile_layer_when_its_project_reopens() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/cube-2020.tif"), "cube": true})).unwrap(); assert_eq!(r["kind"], "cog", "{r}"); assert_eq!(s.map().tiles.len(), 1); assert!(s.map().tiles[0].url.ends_with("#cube=1"), "{}", s.map().tiles[0].url); let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert_eq!((t.map().tiles.len(), t.map().layers.iter().filter(|l| l.raster.is_some()).count()), (1, 0)); } /// A tile layer's file whose header gives only its bands' wavelengths says they aren't drawn /// with it, as it says of band and class names. #[test] fn a_tile_layer_says_its_wavelengths_need_a_window() { use rustgis_data::raster::cog::{Example, example_with}; let file = example_with(&Example { size: 512, bands: 3, wavelengths: true, ..Example::default() }); let (base, _) = serve_ranges(file); let mut s = Session::new(Project::default()); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{base}/cube-2020.tif"), "cube": true})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter() .any(|t| t.ends_with("drawn in place without its band names, class names or wavelengths: a window (\"bbox\") reads them")), "{said:?}" ); } /// Dated-series keys given as `null` (a script's unused ones) ask for nothing: the GeoTIFF is a /// raster layer read in place, not a tile layer. #[test] fn null_series_keys_leave_a_geotiff_read_in_place() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let mut s = Session::new(Project::default()); let p = json!({"url": format!("{base}/scene.tif"), "start": null, "end": null, "dates": null}); let r = s.run("layer.addUrl", &p).unwrap(); assert_eq!((r["kind"].clone(), r["inPlace"].clone()), (json!("raster"), json!(true)), "{r}"); assert!(s.map().tiles.is_empty()); } /// A layer read in place says its statistics and tools read an overview: once when added, once /// when a project with it opens (its pixels landing then), and not again for the same pixels. #[test] fn in_place_layers_say_their_overview_once() { let (base, _) = serve_ranges(rustgis_data::raster::cog::example(2048, 256)); let said = |s: &Session| s.notices.iter().filter(|n| n.text.contains("raster tools read a 1024 × 1024 overview")).count(); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif")})).unwrap(); // The result names the full size, as a file opened from an overview's does. assert_eq!((r["width"].clone(), r["overviewOf"].clone()), (json!(1024), json!([2048, 2048])), "{r}"); s.run("map.zoomIn", &Value::Null).unwrap(); assert_eq!(said(&s), 1, "{:?}", s.notices.last()); let text = serde_json::to_string(&s.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": text})).unwrap(); s.notice_landed_rasters(); assert_eq!(said(&s), 1, "{:?}", s.notices.last()); } /// A project saved when cloud-optimised GeoTIFFs read in place were tile layers opens them as /// raster layers read in place, each where it was drawn, with its name, visibility, opacity, /// look, connection and anonymity; a dated series stays a tile layer. #[test] fn cog_tile_layers_of_older_projects_open_as_rasters() { use rustgis_data::raster::cog::{Example, example_with}; use rustgis_map::raster::{RasterRenderer, Stretch}; let (base, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 8, ..Example::default() })); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": "points.geojson", "text": "{\"type\":\"FeatureCollection\",\"features\":[]}"})).ok(); let mut text: Value = serde_json::to_value(&s.project).unwrap(); let tile = |name: &str, url: String, opacity: f64, visible: bool| { let mut l = rustgis_map::TileLayer::new(name, rustgis_map::TileKind::Cog, url); // Scene's credit, zoom range, overlay flag and legend: what a raster layer hasn't. if name == "Scene" { l.attribution = "© Example".into(); l.min_zoom = 5; l.overlay = true; l.legends = vec![rustgis_data::ogc::Legend { url: format!("{base}/legend.png"), min_scale: None, max_scale: None }]; l.connection = "conn-scene".into(); } if name == "Aerial" { l.anonymous = true; } let mut t = serde_json::to_value(l).unwrap(); t["opacity"] = json!(opacity); t["visible"] = json!(visible); t }; // Tile layers list the top one first: Scene over Aerial, both under the map's layers. let tiles = json!([ tile("Scene", format!("{base}/scene.tif#bands=5,3,2&range=10,200"), 0.5, false), tile("Series", format!("{base}/{{date:YYYY}}.tif"), 1.0, true), tile("Aerial", format!("{base}/aerial.tif"), 1.0, true), ]); let maps = text.pointer_mut("/maps").and_then(Value::as_array_mut); match maps { Some(maps) => maps[0]["tiles"] = tiles, None => text["map"]["tiles"] = tiles, } let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text.to_string()})).unwrap(); let scene = t.map().layers.iter().find(|l| l.name == "Scene").unwrap(); let rl = scene.raster.as_ref().unwrap(); assert!(rl.in_place && rl.data.is_some() && rl.path.as_deref() == Some(&*format!("{base}/scene.tif")), "{:?}", rl.path); assert_eq!(rl.style.renderer, RasterRenderer::Rgb { red: 4, green: 2, blue: 1 }); assert_eq!(rl.style.stretch, Stretch::custom(10.0, 200.0)); assert!(!scene.visible && (scene.opacity() - 0.5).abs() < 1e-6); // Read again through the connection it came from; an anonymous layer stays one. assert_eq!((scene.connection.as_str(), scene.anonymous), ("conn-scene", false)); let aerial = t.map().layers.iter().find(|l| l.name == "Aerial").unwrap(); assert!(aerial.anonymous && aerial.connection.is_empty(), "{aerial:?}"); // Drawn under the map's layers, as they were, Scene over Aerial. let names: Vec<&str> = t.map().layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names[names.len() - 2..], ["Scene", "Aerial"], "{names:?}"); assert_eq!(t.map().tiles.iter().map(|x| x.name.as_str()).collect::>(), ["Series"]); // Scene's settings a raster layer hasn't are said to be dropped, once; Aerial had none. let said = |t: &Session| t.notices.iter().filter(|n| n.text.contains("which have no attribution, legend, overlay, zoom range")).count(); assert_eq!(said(&t), 1, "{:?}", t.notices.last()); assert!(t.notices.iter().any(|n| n.text.contains("not kept for Scene") && !n.text.contains("Aerial")), "{:?}", t.notices.last()); t.run("map.zoomIn", &Value::Null).unwrap(); assert_eq!(said(&t), 1); } /// A plain COG tile layer added this session (`map.addTiles`, kind `cog`) stays a tile layer, with /// what a raster layer hasn't, through a layer file added and maps imported (each repairs the /// project): it opens as a raster layer read in place once the project is saved and opened again, /// as an older project's does. A map file's plain COG tile layers open as raster layers as the map /// is imported, as a project's do. #[test] fn a_cog_tile_layer_added_this_session_stays_one_until_the_project_opens_again() { use rustgis_map::{TileKind, TileLayer}; let (base, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let url = format!("{base}/scene.tif"); let _ = crate::render::rustgis_tiles::cog::probe_blocking(&url); let mut s = Session::new(Project::default()); s.run("map.addTiles", &json!({"url": url, "kind": "cog", "name": "Scene", "attribution": "© Example"})).unwrap(); let scene = |s: &Session| s.project.maps[0].tiles.iter().find(|t| t.name == "Scene").map(|t| (t.kind, t.attribution.clone())); assert_eq!(scene(&s), Some((TileKind::Cog, "© Example".to_string())), "{:?}", s.project.maps[0].tiles); let mut other = rustgis_map::Map::new("Other"); let id = other.add_dataset(crate::data::Dataset::new("Empty", crate::data::GeometryType::Point, Vec::new())); crate::cmd::layer::add_layer_text(&mut s, &serde_json::to_string(other.layer(id).unwrap()).unwrap()).unwrap(); s.run("map.import", &json!({"text": serde_json::to_string(&other).unwrap()})).unwrap(); assert_eq!(scene(&s), Some((TileKind::Cog, "© Example".to_string())), "converted by a repair"); assert!(s.project.maps[0].layer_by_name("Scene").is_none()); assert!(!s.notices.iter().any(|n| n.text.contains("which have no attribution")), "{:?}", s.notices); // An older map file's plain COG tile layer opens as a raster layer read in place as it is // imported. let mut old = serde_json::to_value(rustgis_map::Map::new("Old")).unwrap(); old["tiles"] = json!([serde_json::to_value(TileLayer::new("Aerial", TileKind::Cog, format!("{base}/aerial.tif"))).unwrap()]); s.run("map.import", &json!({"text": old.to_string()})).unwrap(); let imported = s.project.maps.iter().find(|m| m.name == "Old").unwrap(); let rl = imported.layer_by_name("Aerial").and_then(|l| l.raster.as_ref()).unwrap(); assert!(imported.tiles.is_empty() && rl.in_place && rl.data.is_some(), "{:?}", rl.error); // Saved and opened again, Scene is a raster layer read in place too. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let rl = t.project.maps[0].layer_by_name("Scene").and_then(|l| l.raster.as_ref()).unwrap(); assert!(t.project.maps[0].tiles.is_empty() && rl.in_place && rl.data.is_some(), "{:?}", rl.error); } /// A server answering every request with `body`, logging each as `HEAD`, `GET` or `GET `. /// Its HEAD answers carry `announce` as `X-TIFF-Header-Length`, `kind` as `Content-Type` and, /// with `ranges`, `Accept-Ranges: bytes`. fn serve_logged( body: Vec, announce: Option<&'static str>, kind: Option<&'static str>, ranges: bool, ) -> (String, std::sync::Arc>>) { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let kept = log.clone(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; let mut buf = [0u8; 4096]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]).to_ascii_lowercase(); let mut headers = String::new(); if let Some(k) = kind { headers.push_str(&format!("Content-Type: {k}\r\n")); } if req.starts_with("head ") { kept.lock().unwrap().push("HEAD".to_string()); if let Some(n) = announce { headers.push_str(&format!("X-TIFF-Header-Length: {n}\r\n")); } if ranges { headers.push_str("Accept-Ranges: bytes\r\n"); } let _ = write!(s, "HTTP/1.1 200 OK\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n", body.len()); continue; } let range = req.lines().find_map(|l| l.strip_prefix("range: bytes=")).map(|r| r.trim().to_string()); kept.lock().unwrap().push(range.as_ref().map_or("GET".to_string(), |r| format!("GET {r}"))); let bounds = range.as_deref().and_then(|r| { let (a, b) = r.split_once('-')?; let a = a.parse::().ok()?; Some((a, if b.is_empty() { body.len().saturating_sub(1) } else { b.parse::().ok()? })) }); let (status, part) = match bounds { Some((a, b)) if a < body.len() => ("206 Partial Content", &body[a..=b.min(body.len() - 1)]), _ => ("200 OK", &body[..]), }; let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", part.len()); let _ = s.write_all(part); } }); (format!("http://{addr}"), log) } /// A GeoTIFF made on request (Vantor's Transform API) says how long its header is in its HEAD /// answer (`X-TIFF-Header-Length`): the header is read in that one request. Without a usable /// length, the first 64 KiB are read as for any file; a HEAD answer saying JSON skips the /// GeoTIFF read and downloads it. #[test] fn announced_tiff_header_lengths_read_the_header_in_one_request() { let file = rustgis_data::raster::cog::example(2048, 256); let mut s = Session::new(Project::default()); let (base, log) = serve_logged(file.clone(), Some("8192"), Some("image/tiff"), true); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/transform/geotiff?id=1"), "name": "Announced"})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); let asked = log.lock().unwrap().clone(); assert_eq!(asked.first().map(String::as_str), Some("HEAD"), "{asked:?}"); assert_eq!(asked.get(1).map(String::as_str), Some("GET 0-8191"), "the header in one read of its announced length: {asked:?}"); // A length that isn't one: the usual first read. let (base, log) = serve_logged(file.clone(), Some("lots"), None, true); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/transform/geotiff?id=2"), "name": "Unannounced"})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); assert_eq!(log.lock().unwrap().get(1).map(String::as_str), Some("GET 0-65535")); // A file named .tif isn't asked for its headers. let (base, log) = serve_logged(file.clone(), Some("8192"), None, true); s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "name": "Named"})).unwrap(); assert!(!log.lock().unwrap().iter().any(|r| r == "HEAD"), "{:?}", log.lock().unwrap()); // A GeoTIFF labelled as text by a server that answers ranges is still read in place. let (base, log) = serve_logged(file, None, Some("text/plain"), true); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/transform/geotiff?id=3"), "name": "Mislabelled"})).unwrap(); assert_eq!(r["inPlace"], true, "{r}"); assert_eq!(log.lock().unwrap().get(1).map(String::as_str), Some("GET 0-65535")); // JSON by its HEAD answer, from a server that doesn't say it answers ranges: no GeoTIFF // read, downloaded and added. let geojson = br#"{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]}}"#.to_vec(); let (base, log) = serve_logged(geojson, None, Some("application/geo+json"), false); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/api/feature"), "name": "Feature"})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert_eq!(*log.lock().unwrap(), ["HEAD", "GET"], "no GeoTIFF read, then the download"); } /// OpenStreetMap data for an area through an Overpass server (a local one): a layer per geometry /// kind; geoBoundaries' GeoJSON given as text; bad requests refused. #[test] fn osm_and_boundaries() { const ANSWER: &[u8] = br#"{"elements": [ {"type": "node", "id": 1, "lat": 46.945, "lon": 7.445, "tags": {"amenity": "cafe", "name": "Kaffee"}}, {"type": "way", "id": 2, "tags": {"building": "yes"}, "geometry": [{"lat": 46.94, "lon": 7.44}, {"lat": 46.94, "lon": 7.441}, {"lat": 46.941, "lon": 7.441}, {"lat": 46.94, "lon": 7.44}]}, {"type": "way", "id": 3, "tags": {"highway": "residential", "name": "Gasse"}, "geometry": [{"lat": 46.94, "lon": 7.44}, {"lat": 46.945, "lon": 7.445}]} ]}"#; let base = serve(ANSWER, 1); let mut s = Session::new(Project::default()); let r = s.run("osm.download", &json!({"bbox": [7.44, 46.94, 7.45, 46.95], "tags": "amenity, building, highway", "url": base})).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(3), "{r}"); let names: Vec = s.map().layers.iter().map(|l| l.name.clone()).collect(); assert!(names.iter().any(|n| n == "OSM amenity, building, highway (polygons)"), "{names:?}"); // The app's answer, passed in. let r = s .run( "osm.download", &json!({"bbox": [7.44, 46.94, 7.45, 46.95], "preset": "pois", "name": "Cafés", "answer": std::str::from_utf8(ANSWER).unwrap()}), ) .unwrap(); assert_eq!(r["features"]["points"], 1); for bad in [ json!({"bbox": [7.0, 46.0, 8.0, 47.0], "preset": "roads"}), json!({"bbox": [7.44, 46.94, 7.45, 46.95]}), json!({"bbox": [7.44, 46.94, 7.45, 46.95], "preset": "x"}), json!({"bbox": [7.44, 46.94, 7.45, 46.95], "preset": "roads", "answer": "busy"}), json!({"bbox": "here", "preset": "roads"}), // An entry that isn't a number isn't dropped: the box is refused. json!({"bbox": [7.44, "x", 46.94, 7.45, 46.95], "preset": "roads"}), json!({"preset": "roads", "view": false}), ] { assert!(s.run("osm.download", &bad).is_err(), "{bad}"); } let gj = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"shapeName": "Bern"}, "geometry": {"type": "Polygon", "coordinates": [[[7, 46], [8, 46], [8, 47], [7, 46]]]}}]}"#; let camera = s.map().camera; let r = s.run("boundaries.add", &json!({"country": "che", "level": 1, "text": gj})).unwrap(); assert_eq!(r["features"], 1); assert!(s.map().layer_by_name("CHE ADM1").is_some()); assert_eq!(s.map().camera, camera, "adding boundaries preserves an occupied map's view"); let mut empty = Session::new(Project::default()); empty.run("boundaries.add", &json!({"country":"CHE", "text":gj})).unwrap(); let center = rustgis_geom::merc_to_lonlat(empty.map().camera.center); assert!((center.x - 7.5).abs() < 0.1 && (center.y - 46.5).abs() < 0.1, "{center:?}"); for bad in [ json!({}), json!({"country": "CH", "text": gj}), json!({"country": "CHE", "level": "ADM9", "text": gj}), json!({"country": "CHE", "text": "{"}), ] { assert!(s.run("boundaries.add", &bad).is_err(), "{bad}"); } } /// Search Open Data against a local CKAN site: datasets with their resources (what RustGIS can't /// add dropped, the best format first), one added by its address; bad requests are refused. #[test] fn open_data_portal_search_and_add() { let ckan = r#"{"success": true, "result": {"count": 2, "results": [ {"name": "parks", "title": "Parks", "notes": "

City parks & gardens

", "organization": {"title": "City"}, "resources": [{"format": "PDF", "url": "{base}/parks.pdf"}, {"format": "CSV", "url": "{base}/parks.csv"}, {"format": "GeoJSON", "url": "{base}/parks.geojson"}]}, {"name": "report", "title": "A report", "resources": [{"format": "PDF", "url": "{base}/r.pdf"}]} ]}}"#; let gj = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [10, 20]}}]}"#; let base = serve_routes(vec![("/api/3/action/package_search", ckan.to_string()), ("/parks.geojson", gj.to_string())], 2); let mut s = Session::new(Project::default()); let r = s.run("portal.search", &json!({"portal": "ckan", "url": base, "query": "parks"})).unwrap(); assert_eq!(r["count"], 1, "{r}"); let d = &r["datasets"][0]; assert_eq!(d["title"], "Parks"); assert_eq!(d["description"], "City parks & gardens"); let formats: Vec<&str> = d["resources"].as_array().unwrap().iter().filter_map(|x| x["format"].as_str()).collect(); assert_eq!(formats, ["GeoJSON", "CSV"]); let url = d["resources"][0]["url"].as_str().unwrap(); let r = s.run("portal.add", &json!({"url": url, "name": "Parks", "portal": base})).unwrap(); assert_eq!(r["features"], 1, "{r}"); assert!(s.map().layer_by_name("Parks").is_some()); for (id, bad) in [ ("portal.search", json!({"portal": "ckan", "url": base})), ("portal.search", json!({"portal": "nope", "query": "parks"})), ("portal.search", json!({"portal": "ckan", "url": "file:///etc", "query": "parks"})), ("portal.add", json!({})), ("portal.add", json!({"url": "file:///etc/passwd"})), ("portal.add", json!({"url": "https://example.org/x.geojson", "use": "ftp"})), // A public portal's document can't point into the local network. ("portal.add", json!({"url": "http://127.0.0.1/x.geojson", "portal": "https://catalog.data.gov"})), ] { assert!(s.run(id, &bad).is_err(), "{id} {bad}"); } } /// A LAS 1.2 file of point format 0 in longitude/latitude: (x, y, z, class) per point. fn las_bytes(points: &[(f64, f64, f64, u8)]) -> Vec { let mut b = vec![0u8; 227]; b[0..4].copy_from_slice(b"LASF"); b[24] = 1; b[25] = 2; b[94..96].copy_from_slice(&227u16.to_le_bytes()); b[96..100].copy_from_slice(&227u32.to_le_bytes()); b[105..107].copy_from_slice(&20u16.to_le_bytes()); b[107..111].copy_from_slice(&(points.len() as u32).to_le_bytes()); for k in 0..3 { b[131 + k * 8..139 + k * 8].copy_from_slice(&1e-6f64.to_le_bytes()); } // max x, min x, max y, min y, max z, min z. for (at, v) in [(179, 11.0f64), (187, 10.0), (195, 51.0), (203, 50.0), (211, 400.0), (219, 300.0)] { b[at..at + 8].copy_from_slice(&v.to_le_bytes()); } for p in points { let mut r = [0u8; 20]; for (k, v) in [p.0, p.1, p.2].iter().enumerate() { r[k * 4..k * 4 + 4].copy_from_slice(&((v / 1e-6).round() as i32).to_le_bytes()); } r[14] = 0x09; r[15] = p.3; b.extend_from_slice(&r); } b } /// LiDAR point clouds: read in place (a project keeps the file's path, not its points), coloured /// by ASPRS class, from a path and from an upload. #[test] fn lidar_point_clouds() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-lidar-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let pts: Vec<(f64, f64, f64, u8)> = (0..200).map(|i| (10.0 + f64::from(i) / 200.0, 50.5, 300.0 + f64::from(i % 50), if i % 2 == 0 { 2 } else { 6 })).collect(); let path = dir.join("scan.las"); std::fs::write(&path, las_bytes(&pts)).unwrap(); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; let l = s.map().layer(id).unwrap(); match &l.renderer { rustgis_map::Renderer::Unique { field, classes, .. } => { assert_eq!(field, "classification"); let ground = classes.iter().find(|(v, _)| v == "2").unwrap(); assert_eq!(ground.1.fill, rustgis_map::Rgba::rgb(0xA6, 0x7C, 0x52)); } other => panic!("{other:?}"), } // The project names the file; reopened, the points are read from it again. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("scan.las") && text.len() < 20_000, "{}", text.len()); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("scan").unwrap().source.as_ref().unwrap(); assert!(again.is_open(), "{:?}", again.error); // An upload, and one class only: by elevation. let one: Vec<_> = pts.iter().map(|p| (p.0, p.1, p.2, 2)).collect(); let b64 = base64::engine::general_purpose::STANDARD.encode(las_bytes(&one)); let r = s.run("layer.addDataBytes", &json!({"fileName": "ground.las", "data": b64})).unwrap(); let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert!(matches!(&l.renderer, rustgis_map::Renderer::Graduated { field, .. } if field == "z"), "{:?}", l.renderer); // Not a point cloud. assert!(s.run("layer.addDataBytes", &json!({"fileName": "junk.laz", "data": "AAAA"})).is_err()); let _ = std::fs::remove_dir_all(dir); } /// A preview of an oversized cloud must never become a successful whole-data result. #[test] fn lidar_sample_refuses_exports_and_statistics() { let dir = std::env::temp_dir().join(format!("rustgis-lidar-limit-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("oversized.las"); let count = rustgis_data::lidar::MAX_POINTS as u32 + 1; // Only a few records are needed: the header must retain its count even if the file ends early. let mut bytes = las_bytes(&[(10.0, 50.0, 300.0, 2), (10.5, 50.5, 310.0, 2)]); bytes[107..111].copy_from_slice(&count.to_le_bytes()); std::fs::write(&path, bytes).unwrap(); let mut s = Session::new(Project::default()); let added = s.run("layer.addData", &json!({"path": path})).unwrap(); assert_eq!(added["streamed"], true); let id = added["layer"].as_u64().unwrap() as u32; let output = dir.join("out.geojson"); for (command, params) in [ ("share.exportLayer", json!({"layer": id, "path": output})), ("gp.run", json!({"tool": "summaryStatistics", "params": {"input": "oversized", "field": "z"}})), ("layer.loadAll", json!({"layer": id})), ("layer.addData", json!({"path": path, "stream": false})), ] { let error = s.run(command, ¶ms).unwrap_err(); assert!(error.contains(&count.to_string()), "{command}: {error}"); assert!(error.contains(&rustgis_data::lidar::MAX_POINTS.to_string()), "{command}: {error}"); } assert!(!output.exists()); assert_eq!(s.map().layers.len(), 1); std::fs::remove_dir_all(dir).unwrap(); } /// The Zarr tests that read steps share the process's slice cache: one at a time, so another's /// steps don't push out the one a test expects kept. #[cfg(not(target_arch = "wasm32"))] fn zarr_cache_lock() -> std::sync::MutexGuard<'static, ()> { static L: std::sync::Mutex<()> = std::sync::Mutex::new(()); L.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } /// A Zarr store in a temporary folder: `t` over three monthly steps (days since 2001-01-01: 0, /// 31, 59) on a 3 × 4 grid, step `k`'s values `100·k + i`. #[cfg(not(target_arch = "wasm32"))] fn zarr_store_over_time(tag: &str) -> (std::path::PathBuf, String) { zarr_store_at(tag, &[0, 31, 59]) } /// A Zarr store of one variable, `t` (time × 3 × 4), with a step at each of `days` since 2001-01-01: /// step `k`'s cells are `100 k + cell`. #[cfg(not(target_arch = "wasm32"))] fn zarr_store_at(tag: &str, days: &[i32]) -> (std::path::PathBuf, String) { let n = days.len(); let dir = std::env::temp_dir().join(format!("rustgis-zarr-{tag}-{}", std::process::id())); let store = dir.join("air.zarr"); let _ = std::fs::remove_dir_all(&dir); for d in ["t", "lat", "lon", "time"] { std::fs::create_dir_all(store.join(d)).unwrap(); } let arr = |shape: &str, chunks: &str, dtype: &str| { serde_json::from_str::(&format!( r#"{{"shape":{shape},"chunks":{chunks},"dtype":"{dtype}","compressor":null,"fill_value":null,"filters":null,"order":"C","zarr_format":2}}"# )) .unwrap() }; let meta = json!({"metadata": { ".zgroup": {"zarr_format": 2}, "t/.zarray": arr(&format!("[{n},3,4]"), "[1,3,4]", ">(); std::fs::write(store.join("lat/0"), f32s(&[10.0, 20.0, 30.0])).unwrap(); std::fs::write(store.join("lon/0"), f32s(&[1.0, 2.0, 3.0, 4.0])).unwrap(); std::fs::write(store.join("time/0"), days.iter().flat_map(|x| x.to_le_bytes()).collect::>()).unwrap(); for t in 0..n { let v: Vec = (0..12).map(|i| (t * 100 + i) as f32).collect(); std::fs::write(store.join(format!("t/{t}.0.0")), f32s(&v)).unwrap(); } let path = store.display().to_string(); (dir, path) } #[cfg(not(target_arch = "wasm32"))] #[test] fn timed_zarr_open_failures_keep_status_without_store_credentials() { use ogc_server::{Route, Server}; let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("private-open-errors"); let store = std::path::Path::new(&path); let mut routes = Vec::new(); for case in ["first", "selected"] { let root = format!("/{case}?token=hidden-zarr-open"); for key in [".zmetadata", "lat/0", "lon/0", "time/0"] { routes.push(Route::bytes(&format!("{root}/{key}"), std::fs::read(store.join(key)).unwrap())); } if case == "selected" { routes.push(Route::bytes(&format!("{root}/t/0.0.0"), std::fs::read(store.join("t/0.0.0")).unwrap())); } let step = if case == "first" { 0 } else { 1 }; routes.push(Route { status: 503, ..Route::new(&format!("{root}/t/{step}.0.0"), "") }); } let server = Server::new(routes); for case in ["first", "selected"] { let mut s = Session::new(Project::default()); if case == "selected" { s.run("time.set", &json!({"dates":["2001-01-01","2001-02-01","2001-03-01"],"date":"2001-02-01"})).unwrap(); } let url = format!("{}/{case}?token=hidden-zarr-open", server.base); let error = s.run("layer.addZarr", &json!({"url":url,"variable":"t","time":true})).unwrap_err(); assert!(error.contains("503") && !error.contains("hidden-zarr-open"), "{case}: {error}"); assert!(s.map().layers.is_empty(), "an unreadable initial slice must not add a layer"); let mark = s.notice_mark(); s.notify(error, true); let notices = s.notices_since(mark); assert!(notices.iter().any(|n| n.error && n.text.contains("503")), "{case}: {notices:?}"); assert!(notices.iter().all(|n| !n.text.contains("hidden-zarr-open")), "{case}: {notices:?}"); } let requests = server.requests(); for (case, chunk) in [("/first", "t/0.0.0"), ("/selected", "t/0.0.0"), ("/selected", "t/1.0.0")] { assert!( requests.iter().any(|r| r.path == case && r.query.iter().any(|(key, value)| key == "token" && value.ends_with(chunk))), "the first and selected slices must actually be read: {requests:?}" ); } let _ = std::fs::remove_dir_all(dir); } /// A Zarr variable's series at a point (#555): the values along its time dimension at the cell /// holding it, read from the chunks holding that cell, through a layer or the store itself. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zarr_series_at_a_point() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("series"); let mut s = Session::new(Project::default()); // At 1°E 30°N: the stored row 2 (latitudes 10, 20, 30), column 0, so 100·k + 8 at step k. let at = json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0}); let r = s.run("zarr.series", &at).unwrap(); assert_eq!(r["values"], json!([8.0, 108.0, 208.0]), "{r}"); assert_eq!(r["labels"], json!(["2001-01-01 00:00", "2001-02-01 00:00", "2001-03-01 00:00"])); assert_eq!( (r["dimension"].as_str(), r["units"].as_str(), r["row"].as_u64(), r["col"].as_u64()), (Some("time"), Some("K"), Some(2), Some(0)) ); // A point between centres takes the nearest (the axis starts half a cell before the first // centre): 24°N is the 20°N row, 26°N the 30°N row. let mut at = |lat: f64| s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 1.0, "lat": lat})).unwrap()["values"].clone(); let (twenty, thirty) = (at(20.0), at(30.0)); assert_eq!(at(24.0), twenty); assert_eq!(at(26.0), thirty); assert_ne!(twenty, thirty); // Longitudes either way round the globe; another cell. let r = s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 363.5, "lat": 11.0})).unwrap(); assert_eq!(r["values"], json!([3.0, 103.0, 203.0]), "{r}"); // A period. let r = s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0, "start": "2001-01-15"})).unwrap(); assert_eq!(r["values"], json!([108.0, 208.0])); // Through a layer: one that follows the slider (its period), and a slice (the whole axis). let late = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-01-20"})).unwrap()["layer"] .as_u64() .unwrap(); let r = s.run("zarr.series", &json!({"layer": late, "lon": 1.0, "lat": 30.0})).unwrap(); assert_eq!(r["values"], json!([108.0, 208.0])); let slice = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "index": {"time": 1}})).unwrap()["layer"].as_u64().unwrap(); let r = s.run("zarr.series", &json!({"layer": slice, "lon": 1.0, "lat": 30.0})).unwrap(); assert_eq!(r["values"], json!([8.0, 108.0, 208.0])); for bad in [ json!({"path": path, "variable": "t", "lon": 50.0, "lat": 30.0}), json!({"path": path, "variable": "t", "lon": 1.0, "lat": 95.0}), json!({"path": path, "variable": "t", "lat": 30.0}), json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0, "start": "2003-01-01"}), json!({"path": path, "variable": "lat", "lon": 1.0, "lat": 30.0}), json!({"path": "/nowhere/x.zarr", "lon": 1.0, "lat": 30.0}), ] { assert!(s.run("zarr.series", &bad).is_err(), "{bad}"); } // A layer that isn't Zarr. let mut w = Session::new(sample::world_project()); assert!(w.run("zarr.series", &json!({"layer": "Countries", "lon": 1.0, "lat": 30.0})).is_err()); let _ = std::fs::remove_dir_all(dir); } /// A Zarr variable summarised over time (#556): each statistic, a season, a period and a layer's /// period, into a raster layer kept in the project. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zarr_variable_summarised_over_time() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("reduce"); let mut s = Session::new(Project::default()); // The first pixel (north up) is the stored cell 8: 8, 108 and 208 at the three steps. let first = |s: &Session, r: &Value| { let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); let d = l.raster.as_ref().unwrap().data.clone().unwrap(); d.bands[0].data[0] }; for (stat, want) in [("mean", 108.0), ("min", 8.0), ("max", 208.0), ("sum", 324.0), ("count", 3.0)] { let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": stat})).unwrap(); assert_eq!(first(&s, &r), want, "{stat}"); assert_eq!(r["steps"], 3); } let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "std"})).unwrap(); assert!((first(&s, &r) - 81.649_66).abs() < 1e-3, "{}", first(&s, &r)); let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "countAbove", "above": 150})).unwrap(); assert_eq!(first(&s, &r), 1.0); // A season (February alone), and a period. let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mean", "months": [2]})).unwrap(); assert_eq!(first(&s, &r), 108.0); let name = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().name.clone(); assert!(name.starts_with("t mean 2001-02-01–2001-02-01 (months 2)"), "{name}"); let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mean", "start": "2001-01-15"})).unwrap(); assert_eq!(first(&s, &r), 158.0); // Through a layer that follows the slider: its period. let late = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-01-20"})).unwrap()["layer"] .as_u64() .unwrap(); let r = s.run("zarr.reduce", &json!({"layer": late, "stat": "max", "name": "warmest"})).unwrap(); assert_eq!(first(&s, &r), 208.0); assert!(s.map().layer_by_name("warmest").is_some()); // Kept in the project as a GeoTIFF, read again. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("warmest").and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); assert_eq!(again.bands[0].data[0], 208.0); // A step never written (its chunk absent) is Zarr's fill value: no value that step, so the // summary goes on with the others. let (gap_dir, gap) = zarr_store_over_time("reduce-gap"); std::fs::remove_file(std::path::Path::new(&gap).join("t/1.0.0")).unwrap(); let r = s.run("zarr.reduce", &json!({"path": gap, "variable": "t", "stat": "count"})).unwrap(); assert_eq!(first(&s, &r), 2.0); let r = s.run("zarr.reduce", &json!({"path": gap, "variable": "t", "stat": "mean"})).unwrap(); assert_eq!(first(&s, &r), 108.0); let _ = std::fs::remove_dir_all(gap_dir); for bad in [ json!({"path": path, "variable": "t"}), json!({"path": path, "variable": "t", "stat": "median"}), json!({"path": path, "variable": "t", "stat": "countAbove"}), json!({"path": path, "variable": "t", "stat": "mean", "months": [13]}), json!({"path": path, "variable": "t", "stat": "mean", "months": "June"}), json!({"path": path, "variable": "t", "stat": "mean", "minValid": 1.5}), json!({"path": path, "variable": "t", "stat": "mean", "start": "2005-01-01"}), json!({"path": path, "variable": "lat", "stat": "mean"}), ] { assert!(s.run("zarr.reduce", &bad).is_err(), "{bad}"); } let _ = std::fs::remove_dir_all(dir); } /// Where nothing but a shell waits on them (`rustgis-cli run`: `long_runs` set), a trend and a /// summary tell their progress up to its end and give what they give anywhere else; and there a /// STAC trend's or summary's search takes more items than in the app (a state's four MODIS tiles /// over 25 years are 2,304). #[cfg(not(target_arch = "wasm32"))] #[test] fn a_trend_where_nothing_waits_tells_its_progress() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; use std::sync::Mutex; // What was told, by this test alone: no other test sets `long_runs` with this `tell`. static TOLD: Mutex> = Mutex::new(Vec::new()); fn tell(what: &str, share: f64) { assert!((0.0..=1.0).contains(&share), "{what}: {share}"); TOLD.lock().unwrap().push((what.to_string(), share)); } // The last share each run told: its end, 1, however soon it finished, and told only then. let ended = |what: &str| { let told = TOLD.lock().unwrap(); let ends = told.iter().filter(|(w, x)| w == what && *x >= 1.0).count(); assert!(ends <= 1, "{what} told its end {ends} times"); told.iter().rev().find(|(w, _)| w == what).map(|(_, x)| *x) }; let gt = GeoTransform { origin_x: -56.0, origin_y: -12.0, pixel_width: 0.01, pixel_height: -0.01 }; let slope = |s: &mut Session| { let mut names = Vec::new(); for (k, y) in (2013..2025).enumerate() { let name = format!("ndvi_{y}-07-01"); let r = Raster::new(&name, 2, 1, vec![vec![0.3 + 0.02 * k as f32, 0.5]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32") .unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); names.push(name); } let band = |s: &Session, r: &Value| { let id = r["layers"][0].as_u64().unwrap() as u32; s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap().bands[0].data.clone() }; let trend = s.run("cube.trend", &json!({"layers": names})).unwrap(); let mean = s.run("cube.reduce", &json!({"layers": names, "stat": "mean"})).unwrap(); (band(s, &trend), band(s, &mean)) }; let mut plain = Session::new(Project::default()); let mut told = Session::new(Project::default()); told.long_runs = Some(tell); let ((a, mean_a), (b, mean_b)) = (slope(&mut plain), slope(&mut told)); assert_eq!(a, b); assert_eq!(mean_a, mean_b); assert!((a[0] - 0.02).abs() < 1e-4, "{a:?}"); assert!((mean_a[0] - 0.41).abs() < 1e-5 && (mean_a[1] - 0.5).abs() < 1e-6, "{mean_a:?}"); assert_eq!(ended("cube.trend"), Some(1.0), "the trend's progress is told to its end"); assert_eq!(ended("cube.reduce"), Some(1.0), "the summary's progress is told to its end"); // 2,100 items (a tile each, one day) in eleven pages of 200: too many for the time slider and // for a trend in the app, not for a trend or a summary where nothing waits, which go on to // read their first file (here on a closed port). let item = |k: usize| { format!( r#"{{"type": "Feature", "id": "i{k}", "collection": "c", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "2024-01-01T00:00:00Z", "bdc:tiles": ["t{k}"]}}, "assets": {{"NDVI": {{"href": "http://127.0.0.1:9/i{k}.tif"}}}}}}"# ) }; let page = |n: usize| { let items: Vec = (n * 200..((n + 1) * 200).min(2100)).map(item).collect(); let next = if (n + 1) * 200 < 2100 { format!(r#", "links": [{{"rel": "next", "href": "{{base}}/v1/search?page=p{:02}"}}]"#, n + 1) } else { String::new() }; format!(r#"{{"type": "FeatureCollection", "features": [{}]{next}}}"#, items.join(",")) }; // The pages by their marks first: the search's own address is a prefix of theirs. const MARKS: [&str; 10] = [ "/v1/search?page=p01", "/v1/search?page=p02", "/v1/search?page=p03", "/v1/search?page=p04", "/v1/search?page=p05", "/v1/search?page=p06", "/v1/search?page=p07", "/v1/search?page=p08", "/v1/search?page=p09", "/v1/search?page=p10", ]; let mut routes: Vec<(&'static str, String)> = MARKS.iter().enumerate().map(|(i, m)| (*m, page(i + 1))).collect(); routes.push(("/v1/search", page(0))); // Eleven pages for each of the four searches, with room to spare. let api = format!("{}/v1", serve_routes(routes, 60)); let p = json!({"url": api, "collection": "c", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024", "end": "2024"}); let e = plain.run("stac.addCube", &p).unwrap_err(); assert!(e.contains("more than 2000 items"), "{e}"); let e = plain.run("cube.trend", &p).unwrap_err(); assert!(e.contains("more than 2000 items"), "the app's trend doesn't take them: {e}"); // Taken, they go on to the first file, which can't be read. let e = told.run("cube.trend", &p).unwrap_err(); assert!(e.contains("127.0.0.1:9/i") && !e.contains("items"), "a trend takes them: {e}"); let mut summary = p.clone(); summary["stat"] = json!("mean"); let e = told.run("cube.reduce", &summary).unwrap_err(); assert!(e.contains("127.0.0.1:9/i") && !e.contains("items"), "a summary takes them: {e}"); } /// A trend over a data cube (#579), from the three kinds of cube: a stack of raster layers dated /// by their names, a raster whose bands are dates, and a Zarr variable along its time dimension. /// A cell with a clear trend gets Sen's slope and a significant slope; noise gets a slope but no /// significant one; a cell with too few values gets nothing. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_trend_over_a_data_cube() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: -56.0, origin_y: -12.0, pixel_width: 0.01, pixel_height: -0.01 }; // Twelve years; cell 0 rises 0.02 a year, cell 1 is noise, cell 2 has three values only. let years: Vec = (2013..2025).collect(); let noise = [0.03, -0.02, 0.05, -0.04, 0.01, 0.0, -0.05, 0.04, -0.01, 0.02, -0.03, 0.03]; let cells = |k: usize| -> Vec { vec![0.3 + 0.02 * k as f32 + 0.002 * noise[k] as f32, 0.5 + noise[k] as f32, if k < 3 { 0.4 } else { f32::NAN }, 0.6] }; let mut names = Vec::new(); for (k, y) in years.iter().enumerate() { let name = format!("ndvi_{y}-07-01"); let r = Raster::new(&name, 2, 2, vec![cells(k)], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); names.push(name); } let value = |s: &Session, id: &Value, cell: usize| { s.map().layer(id.as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap().bands[0].data[cell] }; let r = s.run("cube.trend", &json!({"layers": names})).unwrap(); let ids = r["layers"].as_array().unwrap().clone(); assert_eq!((ids.len(), r["dates"].as_u64()), (4, Some(12)), "{r}"); let (slope, p, significant) = (&ids[0], &ids[2], &ids[3]); assert!((value(&s, slope, 0) - 0.02).abs() < 1e-3, "{}", value(&s, slope, 0)); assert!(value(&s, p, 0) < 0.01 && (value(&s, significant, 0) - 0.02).abs() < 1e-3); assert!(value(&s, slope, 1).is_finite() && value(&s, p, 1) > 0.05 && value(&s, significant, 1).is_nan()); assert!(value(&s, slope, 2).is_nan() && value(&s, p, 2).is_nan()); // A constant cell: no slope, and nothing significant. assert_eq!(value(&s, slope, 3), 0.0); assert!(value(&s, significant, 3).is_nan()); let name = |id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().name.clone(); assert_eq!(name(slope), "ndvi 2013-07-01–2024-07-01 Sen slope per year"); assert!(name(&ids[1]).ends_with("Z (Hamed-Rao)"), "{}", name(&ids[1])); // Per decade, another test, a period, and the dates given. let r = s.run("cube.trend", &json!({"layers": names, "per": "decade", "test": "yueWang", "start": "2016", "name": "late"})).unwrap(); let late = r["layers"][0].clone(); assert_eq!(r["dates"], 9); assert!((value(&s, &late, 0) - 0.2).abs() < 1e-2, "{}", value(&s, &late, 0)); // An end given as a year takes all of it: 2013 to 2020's July, eight dates (read as 1 January // 2020, it would leave 2020's out). let r = s.run("cube.trend", &json!({"layers": names, "end": "2020", "name": "to2020"})).unwrap(); assert_eq!(r["dates"], 8, "{r}"); let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-06", "name": "toJune"})).unwrap(); assert_eq!(r["dates"], 7, "a month ends with it: 2020's July date is after June: {r}"); // A year given as a number reads the same. let r = s.run("cube.trend", &json!({"layers": names, "end": 2020, "name": "to2020n"})).unwrap(); assert_eq!(r["dates"], 8, "{r}"); // A full date is that day, as before: 1 January 2020 leaves 2020's July out, its 1 July keeps it. let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-01-01", "name": "toNewYear"})).unwrap(); assert_eq!(r["dates"], 7, "{r}"); let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-07-01", "name": "toJuly"})).unwrap(); assert_eq!(r["dates"], 8, "{r}"); let backwards: Vec = (0..12).map(|k| format!("{}-01-01", 2030 - k)).collect(); let r = s.run("cube.trend", &json!({"layers": names, "dates": backwards, "test": "original"})).unwrap(); assert!(value(&s, &r["layers"][0], 0) < 0.0, "the dates given are used, in the layers' order"); // A raster whose bands are the dates, named by them. let bands: Vec> = (0..12).map(cells).collect(); let mut cube = Raster::new("stack", 2, 2, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); for (b, y) in cube.bands.iter_mut().zip(&years) { b.name = format!("NDVI {y}"); } let bytes = geotiff::write(&cube, 0).unwrap(); let id = crate::cmd::raster::add_raster(&mut s, "stack", rustgis_map::RasterLayer::from_bytes(cube, "stack.tif", bytes)).unwrap()["layer"] .clone(); let r = s.run("cube.trend", &json!({"layer": id})).unwrap(); assert!((value(&s, &r["layers"][0], 0) - 0.02).abs() < 1e-3); // Two bands on one date are refused, as two layers are. let same: Vec = (0..12).map(|k| format!("{}-06-01", 2000 + k.max(1))).collect(); let e = s.run("cube.trend", &json!({"layer": id, "dates": same})).unwrap_err(); assert!(e.contains("same date"), "{e}"); // A Zarr variable: every cell rises 100 a step. let (dir, path) = zarr_store_at("trend", &[0, 31, 59, 90, 120, 151, 181, 212]); let r = s.run("cube.trend", &json!({"path": path, "variable": "t", "test": "seasonal", "seasons": 4})).unwrap(); assert_eq!(r["dates"], 8); assert!(value(&s, &r["layers"][0], 0) > 1000.0, "{}", value(&s, &r["layers"][0], 0)); let _ = std::fs::remove_dir_all(dir); // A step never written (its chunk absent) has no value; the trend goes on with the others. let (gap_dir, gap) = zarr_store_at("trend-gap", &[0, 31, 59, 90, 120, 151, 181, 212]); std::fs::remove_file(std::path::Path::new(&gap).join("t/3.0.0")).unwrap(); let r = s.run("cube.trend", &json!({"path": gap, "variable": "t", "test": "original"})).unwrap(); assert!(value(&s, &r["layers"][0], 0) > 1000.0, "{}", value(&s, &r["layers"][0], 0)); let _ = std::fs::remove_dir_all(gap_dir); // Refused: too few layers, no dates, dates of another length, unknown options. for bad in [ json!({"layers": &names[..3]}), json!({"layers": names, "dates": ["2001", "2002"]}), json!({"layers": names, "test": "ols"}), json!({"layers": names, "alpha": 0.7}), json!({"layers": names, "per": "week"}), json!({"layers": names, "start": "2030"}), json!({"layer": names[0]}), ] { assert!(s.run("cube.trend", &bad).is_err(), "{bad}"); } let undated = Raster::new("plain", 2, 2, vec![vec![0.0; 4]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&undated, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "plain", rustgis_map::RasterLayer::from_bytes(undated, "plain.tif", bytes)).unwrap(); let mut no_date = names[..4].to_vec(); no_date[1] = "plain".into(); let e = s.run("cube.trend", &json!({"layers": no_date})).unwrap_err(); assert!(e.contains("no date in the name of plain"), "{e}"); // A run the app no longer keeps is said, never worked out again here. let e = s.run("cube.trend", &json!({"ran": 987_654_321u64})).unwrap_err(); assert!(e.contains("no longer kept"), "{e}"); // A run kept by one session is that session's: another one (Python, tests side by side) // doesn't take it, and it is added once. let kept = Raster::new("kept", 2, 2, vec![vec![0.0; 4]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let ran = crate::cmd::cube::land(&mut s, vec![kept], 12); let mut other = Session::new(Project::default()); assert!(other.run("cube.trend", &json!({"ran": ran})).unwrap_err().contains("no longer kept")); assert_eq!(s.run("cube.trend", &json!({"ran": ran})).unwrap()["dates"], 12); assert!(s.run("cube.trend", &json!({"ran": ran})).is_err()); let e = s.run("cube.trend", &json!({"layers": names, "minValid": 99})).unwrap_err(); assert!(e.contains("more than the"), "{e}"); // Two layers on one day would count twice in the test: refused. let mut twice: Vec = (0..names.len()).map(|k| format!("{}-06-01", 2010 + k)).collect(); twice[1] = twice[0].clone(); let e = s.run("cube.trend", &json!({"layers": names, "dates": twice})).unwrap_err(); assert!(e.contains("same date"), "{e}"); // Seasons from the calendar: the 1st of every month is its month in every year, leap years // included, and a 16-day composite on the same day each year keeps its season. use crate::cmd::cube::season_of; let day = |d: &str| rustgis_data::time::parse_text(d).unwrap(); for y in 2000..2010 { for m in 1..=12 { assert_eq!(season_of(day(&format!("{y}-{m:02}-01")), 12), m - 1, "{y}-{m}"); } assert_eq!(season_of(day(&format!("{y}-01-17")), 23), 1, "{y}"); assert_eq!(season_of(day(&format!("{y}-12-19")), 23), 22, "{y}"); } // A monthly cube dated on the 1st, with a yearly cycle and a rise: the seasonal test finds it // per year and per decade alike. let mut monthly = Vec::new(); for y in 2015..2021 { for m in 1..=12 { let k = (y - 2015) * 12 + (m - 1); let v = 0.5 + 0.2 * (std::f32::consts::TAU * (m - 1) as f32 / 12.0).sin() + 0.01 * k as f32 / 12.0; let name = format!("evi_{y}-{m:02}-01"); let r = Raster::new(&name, 1, 1, vec![vec![v]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); monthly.push(name); } } for (per, want) in [("year", 0.01), ("decade", 0.1)] { let r = s.run("cube.trend", &json!({"layers": monthly, "test": "seasonal", "per": per})).unwrap(); let slope = value(&s, &r["layers"][0], 0); assert!((slope - want).abs() < want * 0.05, "{per}: {slope}"); assert!(value(&s, &r["layers"][2], 0) < 0.01, "{per}: significant"); } } /// A multi-band Float32 GeoTIFF in longitude/latitude, its bands' values interleaved, as GDAL /// writes it: `descriptions` in its GDAL_METADATA tag when given, and -9999 for no-data. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn multiband_tiff(w: u32, h: u32, bands: &[Vec], origin: (f64, f64), px: f64, descriptions: Option<&[String]>) -> Vec { let n = bands.len() as u16; let mut out = vec![b'I', b'I', 42, 0, 0, 0, 0, 0]; for p in 0..(w * h) as usize { for b in bands { out.extend(b[p].to_le_bytes()); } } let bytes = out.len() as u32 - 8; let shorts = |v: &[u16]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); let longs = |v: &[u32]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); let doubles = |v: &[f64]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); let ascii = |s: &str| s.bytes().chain([0]).collect::>(); let mut e: Vec<(u16, u16, u32, Vec)> = vec![ (256, 4, 1, longs(&[w])), (257, 4, 1, longs(&[h])), (258, 3, u32::from(n), shorts(&vec![32; n as usize])), (259, 3, 1, shorts(&[1])), (262, 3, 1, shorts(&[1])), (273, 4, 1, longs(&[8])), (277, 3, 1, shorts(&[n])), (278, 4, 1, longs(&[h])), (279, 4, 1, longs(&[bytes])), (284, 3, 1, shorts(&[1])), (339, 3, u32::from(n), shorts(&vec![3; n as usize])), (33550, 12, 3, doubles(&[px, px, 0.0])), (33922, 12, 6, doubles(&[0.0, 0.0, 0.0, origin.0, origin.1, 0.0])), (34735, 3, 12, shorts(&[1, 1, 0, 2, 1024, 0, 1, 2, 2048, 0, 1, 4326])), ]; if let Some(d) = descriptions { let items: String = d.iter().enumerate().map(|(k, d)| format!(r#"{d}"#)).collect(); let xml = ascii(&format!("{items}")); e.push((42112, 2, xml.len() as u32, xml)); } let nodata = ascii("-9999"); e.push((42113, 2, nodata.len() as u32, nodata)); let ifd_at = out.len() + out.len() % 2; out.resize(ifd_at, 0); let values_at = ifd_at + 2 + 12 * e.len() + 4; let mut values: Vec = Vec::new(); let mut dir = (e.len() as u16).to_le_bytes().to_vec(); for (tag, ty, count, bytes) in &mut e { dir.extend(tag.to_le_bytes()); dir.extend(ty.to_le_bytes()); dir.extend(count.to_le_bytes()); if bytes.len() <= 4 { bytes.resize(4, 0); dir.extend(bytes.iter()); } else { dir.extend(((values_at + values.len()) as u32).to_le_bytes()); values.extend(bytes.iter()); // Each value on a word boundary. if values.len() % 2 == 1 { values.push(0); } } } out[4..8].copy_from_slice(&(ifd_at as u32).to_le_bytes()); out.extend(dir); out.extend([0; 4]); out.extend(values); out } /// A trend over GeoTIFF files (#579, step 3): a stack of files dated by their names (ISO and MODIS /// dates), two of them on other grids, resampled onto the first's; and a multi-band file, a date a /// band, dated by the band descriptions GDAL writes in it or by the dates given. The cells with a /// planted trend have a significant slope; the noisy ones have a slope but no significant one. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_trend_over_stacked_and_multiband_geotiffs() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = Session::new(Project::default()); let dir = std::env::temp_dir().join(format!("rustgis-cube-files-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (x0, y0, px) = (-56.0, -12.0, 0.01); // Twelve years on a grid of 4 × 2 cells: 0 and 5 rise 0.02 a year, 3 and 7 fall 0.01 a year, // 1, 2 and 6 are noise (the same noise turned over or around, never significant), and 4 has // three values only, the file's no-data value on the other dates. let noise = [0.03, -0.02, 0.05, -0.04, 0.01, 0.0, -0.05, 0.04, -0.01, 0.02, -0.03, 0.03]; let cells = |k: usize| -> Vec { let rise = 0.3 + 0.02 * k as f32; let fall = 0.8 - 0.01 * k as f32; let wobble = (0.001 * noise[k]) as f32; vec![ rise + wobble, 0.5 + noise[k] as f32, 0.5 - noise[k] as f32, fall - wobble, if k < 3 { 0.4 } else { -9999.0 }, rise - wobble, 0.5 + noise[11 - k] as f32, fall + wobble, ] }; let years: Vec = (2013..2025).collect(); let mut names = Vec::new(); for (k, y) in years.iter().enumerate() { // Named by a date, or as MODIS names its files (1 July is day 182, 183 in a leap year). let name = if k % 2 == 0 { format!("ndvi_{y}-07-01") } else { format!("MOD13Q1.A{y}{}", if y % 4 == 0 { 183 } else { 182 }) }; let v = cells(k); let (w, h, gt, data) = match k { // Cells half as wide: each value four times. 5 => { let fine = (0..32).map(|i| v[(i / 16) * 4 + (i % 8) / 2]).collect(); (8, 4, GeoTransform { origin_x: x0, origin_y: y0, pixel_width: px / 2.0, pixel_height: -px / 2.0 }, fine) } // A larger area, the grid's cells two columns and a row in from its corner. 8 => { let inside = |i: usize| (2..6).contains(&(i % 7)) && (1..3).contains(&(i / 7)); let wide = (0..28).map(|i| if inside(i) { v[(i / 7 - 1) * 4 + i % 7 - 2] } else { 9.0 }).collect(); (7, 4, GeoTransform { origin_x: x0 - 2.0 * px, origin_y: y0 + px, pixel_width: px, pixel_height: -px }, wide) } _ => (4, 2, GeoTransform { origin_x: x0, origin_y: y0, pixel_width: px, pixel_height: -px }, v), }; let r = Raster::new(&name, w, h, vec![data], gt, Crs::LonLat, Some(-9999.0), ColorHint::Gray, "Float32").unwrap(); let path = dir.join(format!("{name}.tif")); std::fs::write(&path, geotiff::write(&r, 0).unwrap()).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); names.push(name); } let band = |s: &Session, id: &Value| { s.map().layer(id.as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap().bands[0].data.clone() }; // The planted trends are found; the noise, and the cell with three values, are left empty. let found = |s: &Session, r: &Value| { let (slope, p, significant) = (band(s, &r["layers"][0]), band(s, &r["layers"][2]), band(s, &r["layers"][3])); for (cell, want) in [(0, 0.02), (5, 0.02), (3, -0.01), (7, -0.01)] { assert!((significant[cell] - want).abs() < 1e-3, "cell {cell}: {} (p {})", significant[cell], p[cell]); } for cell in [1, 2, 6] { assert!(slope[cell].is_finite() && p[cell] > 0.05 && significant[cell].is_nan(), "cell {cell}: p {}", p[cell]); } assert!(slope[4].is_nan() && significant[4].is_nan()); }; let r = s.run("cube.trend", &json!({"layers": names})).unwrap(); assert_eq!(r["dates"], 12, "{r}"); found(&s, &r); let name = |s: &Session, id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().name.clone(); assert_eq!(name(&s, &r["layers"][0]), "ndvi 2013-07-01–2024-07-01 Sen slope per year"); // Every date counts in every cell, the two resampled ones included, and cell 4 has its three; // the larger file's cells outside the grid are left out. let count = s.run("cube.reduce", &json!({"layers": names, "stat": "count"})).unwrap(); assert_eq!(band(&s, &count["layers"][0]), [12.0, 12.0, 12.0, 12.0, 3.0, 12.0, 12.0, 12.0]); let max = s.run("cube.reduce", &json!({"layers": names, "stat": "max"})).unwrap(); assert!(band(&s, &max["layers"][0]).iter().all(|v| *v < 1.0), "{:?}", band(&s, &max["layers"][0])); // The first layer listed gives the grid: listed first, the finer file gives its 8 × 4 cells. let mut fine_first = names.clone(); fine_first.swap(0, 5); let r = s.run("cube.trend", &json!({"layers": fine_first, "name": "fine"})).unwrap(); let fine = s.map().layer(r["layers"][0].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((fine.width, fine.height), (8, 4)); // A scan not yet georeferenced, on another grid, can't be resampled: refused before the run. let placed = Raster::new( "scan", 3, 3, vec![vec![0.5; 9]], GeoTransform { origin_x: x0, origin_y: y0, pixel_width: px, pixel_height: -px }, Crs::LonLat, None, ColorHint::Gray, "Float32", ) .unwrap(); let bytes = geotiff::write(&placed, 0).unwrap(); let mut scan = placed; scan.georeferenced = false; crate::cmd::raster::add_raster(&mut s, "scan_2030-07-01", rustgis_map::RasterLayer::from_bytes(scan, "scan.tif", bytes)).unwrap(); let mut with_scan = names.clone(); with_scan.push("scan_2030-07-01".into()); let e = s.run("cube.trend", &json!({"layers": with_scan})).unwrap_err(); assert!(e.contains("no coordinates"), "{e}"); // A layer away from the first's area would give its date no value: refused. let gt_away = GeoTransform { origin_x: x0 + 10.0, origin_y: y0, pixel_width: px, pixel_height: -px }; let away = Raster::new("away_2030-07-01", 3, 3, vec![vec![0.5; 9]], gt_away, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&away, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "away_2030-07-01", rustgis_map::RasterLayer::from_bytes(away, "away.tif", bytes)).unwrap(); let mut with_away = names.clone(); with_away.push("away_2030-07-01".into()); let e = s.run("cube.trend", &json!({"layers": with_away})).unwrap_err(); assert!(e.contains("outside the first layer's area"), "{e}"); // A layer in another coordinate system (Web Mercator, 500 m cells) over the grid is resampled // onto it too, through longitude/latitude: every cell gets its date. let merc = |lon: f64, lat: f64| { (6_378_137.0 * lon.to_radians(), 6_378_137.0 * (std::f64::consts::FRAC_PI_4 + lat.to_radians() / 2.0).tan().ln()) }; let (mx, my) = merc(x0 - 0.05, y0 + 0.03); let gt_merc = GeoTransform { origin_x: mx, origin_y: my, pixel_width: 500.0, pixel_height: -500.0 }; let m = Raster::new("merc_2030-07-01", 40, 40, vec![vec![0.5; 1600]], gt_merc, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&m, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "merc_2030-07-01", rustgis_map::RasterLayer::from_bytes(m, "merc.tif", bytes)).unwrap(); let mut with_merc = names.clone(); with_merc.push("merc_2030-07-01".into()); let count = s.run("cube.reduce", &json!({"layers": with_merc, "stat": "count", "name": "merc"})).unwrap(); assert_eq!(band(&s, &count["layers"][0]), [13.0, 13.0, 13.0, 13.0, 4.0, 13.0, 13.0, 13.0]); // A multi-band file, a date a band, its bands named by the descriptions in it. let bands: Vec> = (0..12).map(cells).collect(); let described: Vec = years.iter().map(|y| format!("NDVI {y}-07-01")).collect(); let path = dir.join("ndvi_bands.tif"); std::fs::write(&path, multiband_tiff(4, 2, &bands, (x0, y0), px, Some(&described))).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let file = s.map().layer_by_name("ndvi_bands").unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((file.bands.len(), file.bands[0].name.as_str()), (12, "NDVI 2013-07-01")); let r = s.run("cube.trend", &json!({"layer": "ndvi_bands"})).unwrap(); assert_eq!(r["dates"], 12, "{r}"); found(&s, &r); // An `.aux.xml` next to the file names the bands it describes, the file's own tag the others. let path = dir.join("ndvi_side.tif"); std::fs::write(&path, multiband_tiff(4, 2, &bands, (x0, y0), px, Some(&described))).unwrap(); let sidecar = r#"NDVI 2012-07-01"#; std::fs::write(dir.join("ndvi_side.tif.aux.xml"), sidecar).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let side = s.map().layer_by_name("ndvi_side").unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((side.bands[0].name.as_str(), side.bands[1].name.as_str()), ("NDVI 2012-07-01", "NDVI 2014-07-01")); // What a sidecar leaves out is said as the file is added, and again as a project with it opens; // a raster read whole says nothing either time. let path = dir.join("ndvi_scaled.tif"); std::fs::write(&path, multiband_tiff(4, 2, &bands, (x0, y0), px, Some(&described))).unwrap(); let scaled = r#"0.0001"#; std::fs::write(dir.join("ndvi_scaled.tif.aux.xml"), scaled).unwrap(); let said = "ndvi_scaled: its .aux.xml sidecar: 1 scale or offset(s) not applied: the values are as stored"; let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); assert!(s.notices_since(mark).iter().any(|n| n.text == said), "{:?}", s.notices_since(mark)); let project = dir.join("scaled.rgis"); s.run("project.saveAs", &json!({"path": project.display().to_string()})).unwrap(); let mark = s.notice_mark(); s.run("project.open", &json!({"path": project.display().to_string()})).unwrap(); let opened = s.notices_since(mark); assert_eq!(opened.iter().filter(|n| n.text == said).count(), 1, "{opened:?}"); assert!(!opened.iter().any(|n| n.text.starts_with("ndvi_side:") || n.text.starts_with("ndvi_bands:")), "{opened:?}"); // Two rasters that leave out the same thing are said in one notice that names them both. let twin = dir.join("ndvi_twin.tif"); std::fs::write(&twin, multiband_tiff(4, 2, &bands, (x0, y0), px, Some(&described))).unwrap(); std::fs::write(dir.join("ndvi_twin.tif.aux.xml"), scaled).unwrap(); s.run("layer.addData", &json!({"path": twin.display().to_string()})).unwrap(); s.run("project.saveAs", &json!({"path": project.display().to_string()})).unwrap(); let mark = s.notice_mark(); s.run("project.open", &json!({"path": project.display().to_string()})).unwrap(); let both: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).filter(|n| n.contains("scale or offset")).collect(); assert!( both.len() == 1 && both[0].starts_with("2 rasters (") && both[0].contains("ndvi_scaled") && both[0].contains("ndvi_twin") && both[0].ends_with("): its .aux.xml sidecar: 1 scale or offset(s) not applied: the values are as stored"), "{both:?}" ); // A layer file keeps the file's bytes, not its sidecar: a band name the file's own tag holds // past 256 characters is cut as it is added again from one, and said then too. let mut long = described.clone(); long[0] = "n".repeat(300); let path = dir.join("ndvi_long.tif"); std::fs::write(&path, multiband_tiff(4, 2, &bands, (x0, y0), px, Some(&long))).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let lyr = dir.join("long.rgislyr"); s.run("layer.save", &json!({"layer": "ndvi_long", "path": lyr.display().to_string()})).unwrap(); let mark = s.notice_mark(); s.run("layer.addData", &json!({"path": lyr.display().to_string()})).unwrap(); let again = s.notices_since(mark); assert!(again.iter().any(|n| n.text == "ndvi_long (2): its GDAL metadata: 1 band or class name(s) cut to 256 characters"), "{again:?}"); // Without descriptions, the dates are given. let path = dir.join("ndvi_plain.tif"); std::fs::write(&path, multiband_tiff(4, 2, &bands, (x0, y0), px, None)).unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let e = s.run("cube.trend", &json!({"layer": "ndvi_plain"})).unwrap_err(); assert!(e.contains("give \"dates\""), "{e}"); let typed: Vec = years.iter().map(|y| format!("{y}-07-01")).collect(); let r = s.run("cube.trend", &json!({"layer": "ndvi_plain", "dates": typed, "name": "typed"})).unwrap(); found(&s, &r); let _ = std::fs::remove_dir_all(dir); } /// A data cube summarised over time (#579): each statistic over a stack of layers, a year or a /// month of the year at a time, a cell needing a share of its group's dates, a raster's bands, and /// a Zarr variable as `zarr.reduce` sums it up; each output kept in the project. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_data_cube_summarised_over_time() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; // March and September, 2020 to 2022: cell 0 is the date's place (0 to 5), cell 1 has no value // in 2021, cell 2 is the file's no-data value in March, and cell 3 never has a value. let dates = ["2020-03-01", "2020-09-01", "2021-03-01", "2021-09-01", "2022-03-01", "2022-09-01"]; let mut names = Vec::new(); for (k, d) in dates.iter().enumerate() { let name = format!("ndvi_{d}"); let gap = if d.starts_with("2021") { f32::NAN } else { 1.0 }; let march = if k % 2 == 0 { -9999.0 } else { 7.0 }; let cells = vec![k as f32, gap, march, f32::NAN]; let r = Raster::new(&name, 2, 2, vec![cells], gt, Crs::LonLat, Some(-9999.0), ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); names.push(name); } let data = |s: &Session, id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); let name = |s: &Session, id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().name.clone(); let one = |s: &mut Session, p: Value| { let r = s.run("cube.reduce", &p).unwrap(); assert_eq!(r["layers"].as_array().map(Vec::len), Some(1), "{r}"); data(s, &r["layers"][0]).bands[0].data.clone() }; for (stat, want) in [("mean", 2.5), ("min", 0.0), ("max", 5.0), ("sum", 15.0), ("count", 6.0)] { assert_eq!(one(&mut s, json!({"layers": names, "stat": stat}))[0], want, "{stat}"); } let std = one(&mut s, json!({"layers": names, "stat": "std"}))[0]; assert!((std - 1.707_825).abs() < 1e-4, "{std}"); assert_eq!(one(&mut s, json!({"layers": names, "stat": "countAbove", "above": 2.5}))[0], 3.0); // Gaps and no-data are skipped: cell 1 has four values, cell 2 three, cell 3 none (no mean, // a count of 0). assert_eq!(one(&mut s, json!({"layers": names, "stat": "count"})), [6.0, 4.0, 3.0, 0.0]); let mean = one(&mut s, json!({"layers": names, "stat": "mean"})); assert_eq!(&mean[..3], [2.5, 1.0, 7.0]); assert!(mean[3].is_nan()); assert!(s.map().layer_by_name("ndvi mean 2020-03-01–2022-09-01").is_some()); // A layer a year, named by it, each the mean of its two dates; cell 1 empty in 2021. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "mean", "by": "year"})).unwrap(); let ids = r["layers"].as_array().unwrap().clone(); assert_eq!(r["dates"], 6); assert_eq!(ids.iter().map(|id| name(&s, id)).collect::>(), ["ndvi mean 2020", "ndvi mean 2021", "ndvi mean 2022"]); assert_eq!(ids.iter().map(|id| data(&s, id).bands[0].data[0]).collect::>(), [0.5, 2.5, 4.5]); assert!(data(&s, &ids[1]).bands[0].data[1].is_nan() && data(&s, &ids[2]).bands[0].data[1] == 1.0); // A share of each year's dates: 0.6 of two is two, so cell 2 (one value a year) is empty; // the name given, with the year. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "count", "by": "year", "minValid": 0.6, "name": "anos"})).unwrap(); let ids = r["layers"].as_array().unwrap().clone(); assert_eq!(name(&s, &ids[0]), "anos 2020"); let first = data(&s, &ids[0]).bands[0].data.clone(); assert!(first[0] == 2.0 && first[1] == 2.0 && first[2].is_nan() && first[3].is_nan(), "{first:?}"); // A layer a month of the year, across the years: March's cell 2 is always no-data. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "mean", "by": "month"})).unwrap(); let ids = r["layers"].as_array().unwrap().clone(); assert_eq!(ids.iter().map(|id| name(&s, id)).collect::>(), ["ndvi mean 2020–2022 month 03", "ndvi mean 2020–2022 month 09"]); let (march, september) = (data(&s, &ids[0]).bands[0].data.clone(), data(&s, &ids[1]).bands[0].data.clone()); assert!(march[0] == 2.0 && march[2].is_nan() && september[0] == 3.0 && september[2] == 7.0, "{march:?} {september:?}"); // A season across the years, a year at a time. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "max", "by": "year", "months": [9]})).unwrap(); let ids = r["layers"].as_array().unwrap().clone(); assert_eq!(name(&s, &ids[0]), "ndvi max 2020 (months 9)"); assert_eq!(ids.iter().map(|id| data(&s, id).bands[0].data[0]).collect::>(), [1.0, 3.0, 5.0]); // A raster whose bands are the dates. let bands: Vec> = (0..6).map(|k| vec![k as f32 * 2.0]).collect(); let mut cube = Raster::new("stack", 1, 1, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); for (b, d) in cube.bands.iter_mut().zip(&dates) { b.name = format!("NDVI {d}"); } let bytes = geotiff::write(&cube, 0).unwrap(); let id = crate::cmd::raster::add_raster(&mut s, "stack", rustgis_map::RasterLayer::from_bytes(cube, "stack.tif", bytes)).unwrap()["layer"] .clone(); assert_eq!(one(&mut s, json!({"layer": id, "stat": "mean"})), [5.0]); // A Zarr variable: the same values as zarr.reduce's, cell for cell. let (dir, path) = zarr_store_over_time("cube-reduce"); for stat in ["mean", "std", "max", "count"] { let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": stat})).unwrap(); let zarr = data(&s, &r["layer"]).bands[0].data.clone(); let cube = one(&mut s, json!({"path": path, "variable": "t", "stat": stat})); assert!(zarr.iter().zip(&cube).all(|(a, b)| a == b || (a.is_nan() && b.is_nan())) && zarr.len() == cube.len(), "{stat}"); } let _ = std::fs::remove_dir_all(dir); // Kept in the project as GeoTIFFs, read again. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("anos 2020").and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); assert_eq!(again.bands[0].data[0], 2.0); // Cancelled, it says so. let job = crate::cmd::cube::reduce_start(&mut s, &json!({"layers": names, "stat": "mean"})).unwrap(); let e = job.run(&std::sync::atomic::AtomicBool::new(true), &std::sync::atomic::AtomicUsize::new(0)).unwrap_err(); assert_eq!(e, "cancelled"); // Refused, nothing added. let before = s.map().layers.len(); for bad in [ json!({"layers": names}), json!({"layers": names, "stat": "median"}), json!({"layers": names, "stat": "countAbove"}), json!({"layers": names, "stat": "mean", "by": "week"}), json!({"layers": names, "stat": "mean", "minValid": 1.5}), json!({"layers": names, "stat": "mean", "start": "2030"}), ] { assert!(s.run("cube.reduce", &bad).is_err(), "{bad}"); } assert_eq!(s.map().layers.len(), before); } /// A summary takes a cube a trend can't: two dates are enough for a mean or a maximum, while a /// trend still asks for its few, and each says which it is. #[test] fn a_short_cube_is_summarised_but_not_trended() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; let mut names = Vec::new(); for (k, d) in ["2020-03-01", "2021-03-01"].iter().enumerate() { let name = format!("ndvi_{d}"); let r = Raster::new(&name, 1, 1, vec![vec![k as f32 * 4.0 + 1.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); names.push(name); } for (stat, want) in [("mean", 3.0), ("max", 5.0)] { let r = s.run("cube.reduce", &json!({"layers": names, "stat": stat})).unwrap(); let id = r["layers"][0].as_u64().unwrap() as u32; assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data[0], want, "{stat}"); } let e = s.run("cube.trend", &json!({"layers": names})).unwrap_err(); assert!(e.contains("trend") && !e.contains("summary"), "{e}"); // One date has nothing to summarise. let e = s.run("cube.reduce", &json!({"layers": [names[0]], "stat": "mean"})).unwrap_err(); assert!(e.contains("summary's stack needs 2 layers"), "{e}"); } /// A stack of land-cover maps summarised as classes: each cell's most frequent class (the least /// of those tied) and how many dates had it, how many classes it had, how many times its class /// changed and the class and length of its longest run, in date order (the layers added out of it), how many /// dates it was in some classes, and a layer for each class with its count of dates. A cell left /// without a class at a date (no data) is skipped. What isn't a class, too many classes, and a categorical /// statistic asked of zarr.reduce are refused. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_categorical_stack_summarised_over_time() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; let add = |s: &mut Session, name: &str, cells: Vec| { let r = Raster::new(name, cells.len() as u32, 1, vec![cells], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); }; let nan = f32::NAN; // Four cells over six years: 3 3 4 3 4 4; always 15; 21 21 33 with no data between; and // 5 7 7 7 5 5, whose mode (5, the least of two tied) isn't its longest run's class (7). let years: [(u32, [f32; 4]); 6] = [ (2000, [3.0, 15.0, nan, 5.0]), (2001, [3.0, 15.0, 21.0, 7.0]), (2002, [4.0, 15.0, nan, 7.0]), (2003, [3.0, 15.0, 21.0, 7.0]), (2004, [4.0, 15.0, 33.0, 5.0]), (2005, [4.0, 15.0, nan, 5.0]), ]; let mut s = Session::new(Project::default()); let mut names = Vec::new(); // Added out of date order: changes go by the dates, not the layers' order. for k in [3, 0, 5, 1, 4, 2] { let (y, cells) = years[k]; let name = format!("cobertura_{y}"); add(&mut s, &name, cells.to_vec()); names.push(name); } let run = |s: &mut Session, p: Value| { let r = s.run("cube.reduce", &p).unwrap(); let id = r["layers"][0].as_u64().unwrap() as u32; let l = s.map().layer(id).unwrap(); (l.name.clone(), l.raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data.clone()) }; let (name, mode) = run(&mut s, json!({"layers": names, "stat": "mode"})); assert_eq!(mode, vec![3.0, 15.0, 21.0, 5.0]); assert_eq!(name, "cobertura mode 2000-01-01–2005-01-01"); assert_eq!(run(&mut s, json!({"layers": names, "stat": "majority"})).1, mode); assert_eq!(run(&mut s, json!({"layers": names, "stat": "modeCount"})).1, vec![3.0, 6.0, 2.0, 3.0]); assert_eq!(run(&mut s, json!({"layers": names, "stat": "variety"})).1, vec![2.0, 1.0, 2.0, 2.0]); assert_eq!(run(&mut s, json!({"layers": names, "stat": "changes"})).1, vec![3.0, 0.0, 1.0, 2.0]); // Runs of 2 in 3 and in 4 (the least), 6 in 15, 21 through its gap, 3 in 7. assert_eq!(run(&mut s, json!({"layers": names, "stat": "longestRun"})).1, vec![3.0, 15.0, 21.0, 7.0]); assert_eq!(run(&mut s, json!({"layers": names, "stat": "longestRunLength"})).1, vec![2.0, 6.0, 2.0, 3.0]); assert_eq!(run(&mut s, json!({"layers": names, "stat": "countIn", "classes": [4, 33]})).1, vec![3.0, 0.0, 1.0, 0.0]); // A share of the dates: the third cell has a class at half of them, so it's left out at 0.6. let (_, half) = run(&mut s, json!({"layers": names, "stat": "mode", "minValid": 0.6})); assert_eq!((half[0], half[1], half[3]), (3.0, 15.0, 5.0)); assert!(half[2].is_nan(), "{half:?}"); // Frequency: a layer for each class met, each cell's count of dates in it. let layers = |s: &Session, r: &Value| -> Vec<(String, Vec)> { r["layers"] .as_array() .unwrap() .iter() .map(|id| { let l = s.map().layer(id.as_u64().unwrap() as u32).unwrap(); (l.name.clone(), l.raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data.clone()) }) .collect() }; let r = s.run("cube.reduce", &json!({"layers": names, "stat": "frequency"})).unwrap(); let got = layers(&s, &r); let want: Vec<(String, Vec)> = [ (3, [3.0, 0.0, 0.0, 0.0]), (4, [3.0, 0.0, 0.0, 0.0]), (5, [0.0, 0.0, 0.0, 3.0]), (7, [0.0, 0.0, 0.0, 3.0]), (15, [0.0, 6.0, 0.0, 0.0]), (21, [0.0, 0.0, 2.0, 0.0]), (33, [0.0, 0.0, 1.0, 0.0]), ] .iter() .map(|(c, n)| (format!("cobertura frequency 2000-01-01–2005-01-01 class {c}"), n.to_vec())) .collect(); assert_eq!(got, want); // With a share, a cell with too few values is left out of every class's layer. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "frequency", "minValid": 0.6, "name": "freq"})).unwrap(); let got = layers(&s, &r); assert_eq!(got.len(), 7); assert!(got.iter().all(|(_, n)| n[2].is_nan()), "{got:?}"); assert_eq!(got[0].0, "freq class 3"); assert_eq!((got[0].1[0], got[0].1[1], got[0].1[3]), (3.0, 0.0, 0.0)); // A raster whose bands are the years out of order, their dates given in the bands' order: // changes still go by the dates. let order = [3, 0, 5, 1, 4, 2]; let bands: Vec> = order.iter().map(|&k| years[k].1.to_vec()).collect(); let dates: Vec = order.iter().map(|&k| format!("{}-01-01", years[k].0)).collect(); let r = Raster::new("bandas", 4, 1, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "bandas", rustgis_map::RasterLayer::from_bytes(r, "bandas.tif".to_string(), bytes)).unwrap(); assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "changes"})).1, vec![3.0, 0.0, 1.0, 2.0]); assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "longestRun"})).1, vec![3.0, 15.0, 21.0, 7.0]); assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "longestRunLength"})).1, vec![2.0, 6.0, 2.0, 3.0]); // By year, a change is one within the year: none, with one date a year. let r = s.run("cube.reduce", &json!({"layers": names, "stat": "changes", "by": "year"})).unwrap(); assert_eq!(r["layers"].as_array().unwrap().len(), 6); // Every key a categorical summary takes is read, none said to be ignored. let mark = s.notice_mark(); run(&mut s, json!({"layers": names, "stat": "countIn", "classes": [3], "minValid": 0.5, "name": "floresta"})); let ignored: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).filter(|t| t.contains("ignores")).collect(); assert!(ignored.is_empty(), "{ignored:?}"); // Refused, nothing added. let before = s.map().layers.len(); for (bad, said) in [ (json!({"layers": names, "stat": "countIn"}), "countIn takes \"classes\""), (json!({"layers": names, "stat": "countIn", "classes": []}), "countIn takes \"classes\""), (json!({"layers": names, "stat": "countIn", "classes": [3.5]}), "whole numbers from 0 to 255"), (json!({"layers": names, "stat": "countIn", "classes": [256]}), "whole numbers from 0 to 255"), (json!({"layers": names, "stat": "countIn", "classes": "3"}), "countIn takes \"classes\""), (json!({"layers": names, "stat": "countIn", "classes": vec![3; 257]}), "countIn takes \"classes\""), ] { let e = s.run("cube.reduce", &bad).unwrap_err(); assert!(e.contains(said), "{bad}: {e}"); } // Values that aren't classes (an NDVI): said, with the value. let ndvi = ["ndvi_2020", "ndvi_2021"]; for (k, name) in ndvi.iter().enumerate() { add(&mut s, name, vec![0.25 + k as f32 * 0.5]); } let before_ndvi = s.map().layers.len(); for stat in ["mode", "variety", "changes", "countIn"] { let e = s.run("cube.reduce", &json!({"layers": ndvi, "stat": stat, "classes": [0]})).unwrap_err(); assert!(e.contains("0.25 isn't a class") && e.contains(stat), "{stat}: {e}"); } assert_eq!(s.map().layers.len(), before_ndvi); // More classes than a land-cover map has: refused, not a count for each. let many = ["muitas_2020", "muitas_2021"]; for name in many { add(&mut s, name, (0..1001).map(|c| c as f32).collect()); } let after_many = s.map().layers.len(); let e = s.run("cube.reduce", &json!({"layers": many, "stat": "variety"})).unwrap_err(); assert!(e.contains("more than 1000 classes") && e.contains("variety"), "{e}"); assert_eq!(s.map().layers.len(), after_many); assert_eq!(before_ndvi - before, ndvi.len()); // A Zarr axis out of date order (days 31, 0, 59) whose classes are 2, 1, 2 along it: in date // order they are 1, 2, 2, one change, not the axis's two, and a longest run in 2, where the // axis's runs are all of one date (and the least class, 1, would win). let (dir, path) = zarr_store_at("cube-reduce-classes-order", &[31, 0, 59]); for (t, class) in [(0, 2.0f32), (1, 1.0), (2, 2.0)] { let chunk: Vec = std::iter::repeat_n(class, 12).flat_map(|x| x.to_le_bytes()).collect(); std::fs::write(std::path::Path::new(&path).join(format!("t/{t}.0.0")), chunk).unwrap(); } assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "changes"})).1, vec![1.0; 12]); assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "longestRun"})).1, vec![2.0; 12]); assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "longestRunLength"})).1, vec![2.0; 12]); let _ = std::fs::remove_dir_all(dir); // zarr.reduce doesn't take them: it points to cube.reduce. let (dir, path) = zarr_store_over_time("cube-reduce-classes"); let e = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mode"})).unwrap_err(); assert!(e.contains("cube.reduce"), "{e}"); let _ = std::fs::remove_dir_all(dir); } /// A Zarr axis in decreasing order: a trend refuses it, a summary takes it and names its period from /// the earliest date to the latest. #[test] fn a_summary_over_a_decreasing_zarr_axis_names_its_period_in_order() { let (dir, path) = zarr_store_at("cube-reduce-desc", &[59, 31, 0]); let mut s = Session::new(Project::default()); let e = s.run("cube.trend", &json!({"path": path, "variable": "t"})).unwrap_err(); assert!(e.contains("increasing order"), "{e}"); let r = s.run("cube.reduce", &json!({"path": path, "variable": "t", "stat": "mean"})).unwrap(); let name = s.map().layer(r["layers"][0].as_u64().unwrap() as u32).unwrap().name.clone(); assert!(name.contains("2001-01-01–2001-03-01"), "{name}"); let _ = std::fs::remove_dir_all(dir); } /// A STAC collection's band summarised over time, read in place as for a trend: each file's own /// scale and offset taken, the whole period or a month at a time. #[test] fn a_stac_collection_summarised_over_time() { let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); let dates = ["2024-01-01", "2024-01-17", "2024-02-02", "2024-02-18", "2024-03-05", "2024-03-21"]; let features: Vec = dates .iter() .enumerate() .map(|(k, d)| { format!( r#"{{"type": "Feature", "id": "n{k}", "collection": "mod", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "{d}T00:00:00Z"}}, "assets": {{"NDVI": {{"href": "{cog}/n{k}.tif", "eo:bands": [{{"scale": {}, "offset": {}}}]}}}}}}"#, (k + 1) as f64 * 1e-4, k as f64 * 0.01 ) }) .collect(); let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, features.join(",")); let api = format!("{}/v1", serve_routes(vec![("/v1/search", search)], 8)); let mut s = Session::new(Project::default()); let mut run = |by: &str| { let p = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "stat": "mean", "by": by}); let r = s.run("cube.reduce", &p).unwrap(); let ids: Vec = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap() as u32).collect(); ids.iter().map(|id| s.map().layer(*id).unwrap().raster.as_ref().unwrap().data.clone().unwrap()).collect::>() }; // Pixel (x, y) stores (x + y) % 256; date k reads it at a scale of k + 1 ten-thousandths and // an offset of k hundredths, so dates k0 to k1 average to (k0 + k1 + 2) / 2 ten-thousandths // of it plus (k0 + k1) / 2 hundredths. let mean = |x: usize, y: usize, k0: usize, k1: usize| { ((x + y) % 256) as f64 * (k0 + k1 + 2) as f64 / 2.0 * 1e-4 + (k0 + k1) as f64 / 2.0 * 0.01 }; let whole = run("all"); assert_eq!(whole.len(), 1); assert_eq!((whole[0].width, whole[0].height), (512, 512), "the area on the full images' grid"); let months = run("month"); assert_eq!(months.len(), 3, "January, February and March"); for (x, y) in [(10, 20), (300, 100), (0, 0), (511, 511)] { let at = y * 512 + x; let got = f64::from(whole[0].bands[0].data[at]); assert!((got - mean(x, y, 0, 5)).abs() < 1e-4, "({x}, {y}): {got} vs {}", mean(x, y, 0, 5)); for (m, (k0, k1)) in [(0, 1), (2, 3), (4, 5)].into_iter().enumerate() { let got = f64::from(months[m].bands[0].data[at]); assert!((got - mean(x, y, k0, k1)).abs() < 1e-4, "month {m} ({x}, {y}): {got} vs {}", mean(x, y, k0, k1)); } } } /// A Zarr variable follows the time slider (#554): the slice drawn is the step the slider is at, /// the stretch is the same at every step, and the project keeps the layer by its store. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zarr_variable_follows_the_time_slider() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("follow"); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; // The slider is on over the variable's three dates, at the first. let info = s.run("time.info", &Value::Null).unwrap(); assert_eq!(info["steps"], 3, "{info}"); let first = |s: &Session| s.map().layer(id).and_then(|l| l.raster.as_ref()).and_then(|r| r.data.as_ref()).map(|d| d.bands[0].data[0]); // North up: the last latitude row first; step k's values are 100·k + i. assert_eq!(first(&s), Some(8.0)); let rl = s.map().layer(id).unwrap().raster.clone().unwrap(); assert!(matches!(rl.style.stretch, rustgis_map::raster::Stretch::Custom { .. }), "{:?}", rl.style.stretch); assert!(s.map().layer(id).unwrap().name.ends_with(" t"), "{}", s.map().layer(id).unwrap().name); let stretch = rl.style.stretch; // Moving the slider moves the slice; the stretch stays. s.run("time.goTo", &json!({"step": 2})).unwrap(); assert_eq!(first(&s), Some(208.0)); s.run("time.step", &json!({"by": -1})).unwrap(); assert_eq!(first(&s), Some(108.0)); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().style.stretch, stretch); // Saved and opened again: the store and the step, read again. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("\"time_zarr\"") && !text.contains("\"embedded\""), "kept by its store, not as a slice"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t .map() .layers .iter() .find(|l| l.is_raster()) .and_then(|l| l.raster.as_ref()) .and_then(|r| r.data.as_ref()) .map(|d| d.bands[0].data[0]); assert_eq!(again, Some(108.0)); // A window with no step of the variable: nothing drawn. s.run("time.set", &json!({"dates": ["2000-06-01", "2001-01-01", "2001-02-01", "2001-03-01"], "date": "2000-06-01"})).unwrap(); assert_eq!(first(&s), None); s.run("time.goTo", &json!({"step": 3})).unwrap(); assert_eq!(first(&s), Some(208.0)); // The animation reads each step's slice. let zip = std::env::temp_dir().join(format!("rustgis-zarr-frames-{}.zip", std::process::id())); let r = s.run("time.export", &json!({"path": zip.display().to_string(), "width": 64, "height": 48})).unwrap(); assert_eq!(r["frames"], 4, "{r}"); // After the export the slider is back at its step, and the layer shows it. assert_eq!(first(&s), Some(208.0), "the layer shows the step the slider is back at"); let _ = std::fs::remove_file(zip); // A period: the steps from February on, the layer's first step the second of the axis. let id2 = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-01-20", "name": "late"})).unwrap() ["layer"] .as_u64() .unwrap() as u32; let zt = s.map().layer(id2).unwrap().raster.as_ref().unwrap().time_zarr.clone().unwrap(); assert_eq!((zt.offset, zt.dates.len(), zt.dim.as_str()), (1, 2, "time")); for bad in [ json!({"path": path, "variable": "t", "time": true, "start": "2002-01-01"}), json!({"path": path, "variable": "t", "time": true, "start": "June"}), json!({"path": path, "variable": "t", "time": true, "ran": 1}), json!({"path": path, "variable": "lat", "time": true}), ] { assert!(s.run("layer.addZarr", &bad).is_err(), "{bad}"); } // An axis with a date twice, or going back: each date is a step, so it can't be followed. let (dir2, twice) = zarr_store_over_time("twice"); std::fs::write(std::path::Path::new(&twice).join("time/0"), [0i32, 31, 31].iter().flat_map(|x| x.to_le_bytes()).collect::>()) .unwrap(); let e = s.run("layer.addZarr", &json!({"path": twice, "variable": "t", "time": true})).unwrap_err(); assert!(e.contains("increasing order"), "{e}"); // With no variable named, a folder's first one follows the slider. let mut plain = Session::new(Project::default()); let r = plain.run("layer.addZarr", &json!({"path": path, "time": true})).unwrap(); assert!(plain.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().time_zarr.is_some()); // A store whose first slice can't be read leaves the slider as it was. let (dir3, broken) = zarr_store_over_time("broken"); std::fs::remove_file(std::path::Path::new(&broken).join("t/0.0.0")).unwrap(); std::fs::write(std::path::Path::new(&broken).join("t/0.0.0"), b"short").unwrap(); let mut fresh = Session::new(Project::default()); assert!(fresh.run("layer.addZarr", &json!({"path": broken, "variable": "t", "time": true})).is_err()); assert!(fresh.map().time.is_none(), "a store that can't be read leaves the slider as it was"); let _ = std::fs::remove_dir_all(dir3); let _ = std::fs::remove_dir_all(dir2); let _ = std::fs::remove_dir_all(dir); } /// Manual reload reads changed chunks even when folder metadata is unchanged, and later time /// steps cannot revive a cached pre-reload slice. #[cfg(not(target_arch = "wasm32"))] #[test] fn manual_zarr_reload_reads_fresh_chunks_and_keeps_the_time_variable() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("manual-reload"); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; let snapshot = s.run("layer.addZarr", &json!({"path": path, "variable": "t[time=0]", "name": "Slice"})).unwrap()["layer"].as_u64().unwrap() as u32; let first = |s: &Session, id| s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data[0]; assert_eq!(first(&s, id), 8.0); s.run("time.goTo", &json!({"step": 1})).unwrap(); assert_eq!(first(&s, id), 108.0); s.run("time.goTo", &json!({"step": 0})).unwrap(); let style = serde_json::to_value(&s.map().layer(id).unwrap().raster.as_ref().unwrap().style).unwrap(); let prior = s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.clone().unwrap(); crate::cmd::zarr::read_step(&prior, 2, crate::cmd::zarr::slice_key(&prior, 2)).unwrap(); for step in 0..3 { let chunk: Vec = std::iter::repeat_n(500.0f32 + step as f32, 12).flat_map(|v| v.to_le_bytes()).collect(); std::fs::write(std::path::Path::new(&path).join(format!("t/{step}.0.0")), chunk).unwrap(); } s.run("layer.reloadData", &json!({"layer": id})).unwrap(); assert_eq!(first(&s, id), 500.0); assert_eq!(first(&s, snapshot), 8.0, "each target reloads independently"); let summary = s.run("zarr.reduce", &json!({"layer": id, "stat": "mean"})).unwrap()["layer"].as_u64().unwrap() as u32; assert_eq!(first(&s, summary), 501.0, "summaries must not reuse pre-reload slices"); s.run("layer.reloadData", &json!({"layer": snapshot})).unwrap(); assert_eq!(first(&s, snapshot), 500.0); for params in [json!({"layer": snapshot, "stat": "mean"}), json!({"path": path, "variable": "t", "stat": "mean"})] { let summary = s.run("zarr.reduce", ¶ms).unwrap()["layer"].as_u64().unwrap() as u32; assert_eq!(first(&s, summary), 501.0, "ordinary and path analyses read fresh chunks"); let cube = s.run("cube.reduce", ¶ms).unwrap()["layers"][0].as_u64().unwrap() as u32; assert_eq!(first(&s, cube), 501.0, "cube analyses use the same fresh series policy"); } // Reopening in this same process must not revive an older cached generation. let saved = serde_json::to_string(&s.project).unwrap(); let mut reopened = Session::new(Project::default()); reopened.run("project.openText", &json!({"text": saved})).unwrap(); assert_eq!(first(&reopened, id), 500.0); // An old in-flight read may finish after the project reopened, but cannot match it. let key = crate::cmd::zarr::slice_key(&prior, 2); crate::cmd::zarr::read_step(&prior, 2, key).unwrap(); reopened.run("time.goTo", &json!({"step": 2})).unwrap(); assert_eq!(first(&reopened, id), 502.0); s.run("time.goTo", &json!({"step": 1})).unwrap(); assert_eq!(first(&s, id), 501.0); let rl = s.map().layer(id).unwrap().raster.as_ref().unwrap(); assert_eq!(rl.time_zarr.as_ref().unwrap().variable, "t"); assert_eq!(serde_json::to_value(&rl.style).unwrap(), style); let old = rl.data.clone().unwrap(); std::fs::write(std::path::Path::new(&path).join("t/1.0.0"), b"truncated").unwrap(); assert!(s.run("layer.reloadData", &json!({"layer": id})).is_err()); assert!(std::sync::Arc::ptr_eq(&old, s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap())); std::fs::remove_dir_all(dir).unwrap(); } /// Step `k`'s cells of [`zarr_store_at`]'s variable (`100 k + cell`), as its chunk file holds them. #[cfg(not(target_arch = "wasm32"))] fn f32s_of(base: i32) -> Vec { (0..12).flat_map(|i| ((base + i) as f32).to_le_bytes()).collect() } /// In the app a time slider move doesn't wait for a Zarr step (#1291 step 3): the step is read on /// a thread of its own, the layer showing the step before until it lands; one that fails says why /// once it lands. An animation's frames still read each step before they draw. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zarr_step_is_read_off_the_frame_in_the_app() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("offframe"); let mut s = Session::new(Project::default()); s.zarr_off_frame = true; let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; let first = |s: &Session| s.map().layer(id).and_then(|l| l.raster.as_ref()).and_then(|r| r.data.as_ref()).map(|d| d.bands[0].data[0]); assert_eq!(first(&s), Some(8.0)); let landed = |s: &mut Session, want: Option| { let end = std::time::Instant::now() + std::time::Duration::from_secs(30); while first(s) != want { assert!(std::time::Instant::now() < end, "the step never landed: {:?}", first(s)); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(5)); } }; // The move returns at once: the layer shows the step before, then step 2 once it lands; // playing waits for it meanwhile. s.run("time.goTo", &json!({"step": 2})).unwrap(); landed(&mut s, Some(208.0)); assert!(!s.zarr_waiting(), "the step shown is the slider's"); // A step read before shows at once. s.run("time.goTo", &json!({"step": 0})).unwrap(); assert_eq!(first(&s), Some(8.0), "a kept step shows without waiting"); // A step that can't be read empties the layer and says why, once. std::fs::write(std::path::Path::new(&path).join("t/1.0.0"), b"short").unwrap(); let mark = s.notice_mark(); s.run("time.goTo", &json!({"step": 1})).unwrap(); landed(&mut s, None); let errors: Vec<_> = s.notices_since(mark).iter().filter(|n| n.error).map(|n| n.text.clone()).collect(); assert_eq!(errors.len(), 1, "{errors:?}"); s.follow_time_landed(); assert_eq!(s.notices_since(mark).iter().filter(|n| n.error).count(), 1, "said once"); // Two layers on the failing step: one notice, with a count. s.run("time.goTo", &json!({"step": 0})).unwrap(); s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "name": "again"})).unwrap(); let mark = s.notice_mark(); s.run("time.goTo", &json!({"step": 1})).unwrap(); let end = std::time::Instant::now() + std::time::Duration::from_secs(30); while s.zarr_waiting() { assert!(std::time::Instant::now() < end, "the failing step never landed"); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(5)); } let errors: Vec<_> = s.notices_since(mark).iter().filter(|n| n.error).map(|n| n.text.clone()).collect(); assert!(errors.len() == 1 && errors[0].contains("and 1 more layer:"), "{errors:?}"); // A script's line waits for its step, so the next line reads it. s.run("time.goTo", &json!({"step": 0})).unwrap(); std::fs::write(std::path::Path::new(&path).join("t/1.0.0"), f32s_of(100)).unwrap(); let ran = s.run("script.run", &json!({"script": "time.goTo {\"step\": 1}"})).unwrap(); assert!(ran["ran"].as_array().is_some_and(|r| r.len() == 1), "{ran}"); assert_eq!(first(&s), Some(108.0), "a script's move is read before its next line"); // The animation's frames draw their own steps: step 2 is read before its frame. let zip = std::env::temp_dir().join(format!("rustgis-zarr-offframe-{}.zip", std::process::id())); s.run("time.goTo", &json!({"step": 0})).unwrap(); let r = s.run("time.export", &json!({"path": zip.display().to_string(), "width": 64, "height": 48, "from": "2001-03-01"})).unwrap(); assert_eq!(r["frames"], 1, "{r}"); let _ = std::fs::remove_file(zip); let _ = std::fs::remove_dir_all(dir); } #[cfg(not(target_arch = "wasm32"))] #[test] fn permission_review_zarr_actorless_scoped_moves_wait_for_the_requested_step() { use crate::policy::{AgentAccess, Call, Effects, Outcome, Revocation, Scope}; use std::sync::Arc; struct Live; impl Revocation for Live { fn allows(&self, _: Effects) -> bool { true } } let _cache = zarr_cache_lock(); for agent in [false, true] { let (dir, path) = zarr_store_over_time(if agent { "review-agent-step" } else { "review-assistant-step" }); let mut s = Session::new(Project::default()); s.zarr_off_frame = true; let id = s.run("layer.addZarr", &json!({"path":path,"variable":"t","time":true})).unwrap()["layer"].as_u64().unwrap() as u32; let live = Arc::new(Live); let scope = if agent { Scope::agent(AgentAccess::Allow, false, "zarr-actorless", None, live) } else { Scope::assistant(1461, live) }; let call = Call::command("time.goTo", json!({"step":1})); if agent { assert!(matches!(s.review(&scope, &call), Outcome::Allowed)); s.run_scoped(scope, &call).unwrap(); } else { let Outcome::NeedsApproval(pending) = s.review(&scope, &call) else { panic!("the assistant's local read must ask") }; s.run_permitted(scope, pending.allow(), &call).unwrap(); } // No frame has landed a worker: the actual command must have shown its own requested step. let raster = s.map().layer(id).unwrap().raster.as_ref().unwrap(); assert_eq!(raster.time_zarr.as_ref().unwrap().shown, Some(1)); assert_eq!(raster.data.as_ref().unwrap().bands[0].data[0], 108.0); assert!(s.actor.is_none() && !s.zarr_waiting()); std::fs::remove_dir_all(dir).unwrap(); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn permission_review_zarr_worker_rechecks_captured_still_before_a_local_read() { if isolate_process_test("tests::permission_review_zarr_worker_rechecks_captured_still_before_a_local_read") { return; } use crate::policy::{AgentAccess, Effects, Revocation, Scope}; use crate::render::rustgis_tiles::cache; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }; let _cache = zarr_cache_lock(); struct Live; impl Revocation for Live { fn allows(&self, _: Effects) -> bool { true } } let mut failures = Vec::new(); for (tag, revoke_before, revoke_after, foreign_poll, scoped_poll) in [ ("native", false, false, false, false), ("revoked-before", true, false, false, false), ("revoked-after", false, true, false, false), ("other-session", true, false, true, false), ("foreign-scope", true, false, false, true), ] { let (dir, path) = zarr_store_over_time(&format!("review-worker-{tag}")); let mut s = Session::new(Project::default()); s.zarr_off_frame = true; let id = s.run("layer.addZarr", &json!({"path":path,"variable":"t","time":true})).unwrap()["layer"].as_u64().unwrap() as u32; let zt = s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.as_ref().unwrap(); let key = crate::cmd::zarr::slice_key(zt, 1); let caller = std::thread::current().id(); let live = Arc::new(AtomicBool::new(true)); let current = live.clone(); let held = AtomicBool::new(false); let (started, entered) = mpsc::sync_channel(1); let (release, released) = mpsc::sync_channel(1); let released = Mutex::new(released); let still = Arc::new(move || { if std::thread::current().id() != caller && !held.swap(true, Ordering::SeqCst) { let _ = started.send(()); if released.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).is_err() { return false; } } current.load(Ordering::SeqCst) }); let mut completed = 0; crate::cmd::zarr::landed_since(&mut completed); let mark = s.notice_mark(); cache::with_still(still, || s.run("time.goTo", &json!({"step":1}))).unwrap(); entered.recv_timeout(std::time::Duration::from_secs(10)).expect("the actual worker checks its initiating predicate"); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.as_ref().unwrap().shown, Some(0)); live.store(!revoke_before, Ordering::SeqCst); release.send(()).unwrap(); let end = std::time::Instant::now() + std::time::Duration::from_secs(10); // This isolated scenario has one worker: observe completion before any frame can consume it. while !crate::cmd::zarr::landed_since(&mut completed) { assert!(std::time::Instant::now() < end, "the held worker never completed"); std::thread::sleep(std::time::Duration::from_millis(2)); } if revoke_after { live.store(false, Ordering::SeqCst); } if foreign_poll { let mut other = Session::new(Project::default()); other.zarr_off_frame = true; other.follow_time_landed(); } if scoped_poll { let scope = Scope::agent(AgentAccess::Refuse, false, "foreign-zarr", None, Arc::new(Live)); assert!(!s.with_scope(scope, |s| s.follow_time_landed()).unwrap(), "foreign scope must not consume the native completion"); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.as_ref().unwrap().shown, Some(0)); assert!(s.zarr_waiting(), "the original completion remains for its owner"); } while s.zarr_waiting() { assert!(std::time::Instant::now() < end, "the completed Zarr worker never landed"); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(2)); } let revoked = revoke_before || revoke_after; let raster = s.map().layer(id).unwrap().raster.as_ref().unwrap(); let value = raster.data.as_ref().map(|data| data.bands[0].data[0]); if raster.time_zarr.as_ref().unwrap().shown != Some(1) || value != if revoked { None } else { Some(108.0) } { failures.push(format!("{tag}: completed read applied {value:?}")); } if crate::cmd::zarr::kept_slice(&key).is_some() != !revoke_before { failures.push(format!("{tag}: invalid cache publication")); } let errors = s.notices_since(mark).iter().filter(|notice| notice.error).count(); if errors != usize::from(revoked) { failures.push(format!("{tag}: expected one failure notice when revoked, got {errors}")); } std::fs::remove_dir_all(dir).unwrap(); } assert!(failures.is_empty(), "{failures:?}"); } /// A cached slice and a cold local read both require the current initiating carrier. #[cfg(not(target_arch = "wasm32"))] #[test] fn permission_review_zarr_slice_cache_never_bypasses_a_revoked_reader() { use crate::render::rustgis_tiles::cache; let _cache = zarr_cache_lock(); let mut failures = Vec::new(); for cached in [false, true] { let (dir, path) = zarr_store_over_time(&format!("review-cache-owner-{cached}")); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path":path,"variable":"t","time":true})).unwrap()["layer"].as_u64().unwrap() as u32; let zt = s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.as_ref().unwrap(); let key = crate::cmd::zarr::slice_key(zt, 1); if cached { crate::cmd::zarr::read_step(zt, 1, key.clone()).unwrap(); } let result = cache::with_still(std::sync::Arc::new(|| false), || crate::cmd::zarr::read_step(zt, 1, key.clone())); if result.is_ok() || crate::cmd::zarr::kept_slice(&key).is_some() != cached { failures.push(format!("cached={cached}: revoked reader obtained or published pixels")); } std::fs::remove_dir_all(dir).unwrap(); } assert!(failures.is_empty(), "{failures:?}"); } /// A Zarr store in a folder: its variables listed (with time labels), a slice added by command /// and by Add Data, saved by folder and selection and read again when the project opens; a slice /// the app read itself handed over by id. #[cfg(not(target_arch = "wasm32"))] #[test] fn zarr_stores_in_folders_and_handed_over() { let dir = std::env::temp_dir().join(format!("rustgis-zarr-{}", std::process::id())); let store = dir.join("air.zarr"); let _ = std::fs::remove_dir_all(&dir); for d in ["t", "lat", "lon", "time"] { std::fs::create_dir_all(store.join(d)).unwrap(); } let arr = |shape: &str, chunks: &str, dtype: &str| { serde_json::from_str::(&format!( r#"{{"shape":{shape},"chunks":{chunks},"dtype":"{dtype}","compressor":null,"fill_value":null,"filters":null,"order":"C","zarr_format":2}}"# )) .unwrap() }; let meta = json!({"metadata": { ".zgroup": {"zarr_format": 2}, "t/.zarray": arr("[2,3,4]", "[1,3,4]", ">(); std::fs::write(store.join("lat/0"), f32s(&[10.0, 20.0, 30.0])).unwrap(); std::fs::write(store.join("lon/0"), f32s(&[1.0, 2.0, 3.0, 4.0])).unwrap(); std::fs::write(store.join("time/0"), [0i32, 31].iter().flat_map(|x| x.to_le_bytes()).collect::>()).unwrap(); for t in 0..2 { let v: Vec = (0..12).map(|i| (t * 100 + i) as f32).collect(); std::fs::write(store.join(format!("t/{t}.0.0")), f32s(&v)).unwrap(); } let path = store.display().to_string(); let mut s = Session::new(Project::default()); let info = s.run("raster.zarrInfo", &json!({"path": path})).unwrap(); assert_eq!(info["count"], 1); assert_eq!(info["variables"][0]["dims"][0]["labels"], json!(["2001-01-01", "2001-02-01"])); let r = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "index": {"time": 1}})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(4), Some(3)), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; let ras = s.map().layer(id).unwrap().raster.clone().unwrap(); assert_eq!(ras.subdataset.as_deref(), Some("t[time=1]")); let data = ras.data.clone().unwrap(); // North up: the last latitude row (30°N) first. assert_eq!(data.bands[0].data[..4], [108.0, 109.0, 110.0, 111.0]); // The project names the folder and the slice, and reads it again. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layers.iter().find(|l| l.is_raster()).and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); assert_eq!(again.bands[0].data[..4], [108.0, 109.0, 110.0, 111.0]); // Add Data on the folder: the first variable, first step. let r = s.run("layer.addData", &json!({"path": path})).unwrap(); let first = s.map().layer(r["layer"].as_u64().unwrap() as u32).and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); assert_eq!(first.bands[0].data[0], 8.0); // A slice the app read: its GeoTIFF handed over by id, named by its store and selection. let tif = rustgis_data::raster::geotiff::write(&again, 0).unwrap(); let id = s.download_started(); assert!(s.download_landed(id, tif)); let r = s .run("layer.addZarr", &json!({"url": "https://example.org/data/air.zarr", "variable": "t", "index": {"time": 1}, "ran": id})) .unwrap(); let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert!(l.name.starts_with("air t[time=1]"), "{}", l.name); assert!(s.run("layer.addZarr", &json!({"url": "https://example.org/a.zarr", "ran": id})).is_err(), "used once"); for bad in [ json!({}), json!({"path": path, "variable": "nope"}), json!({"path": path, "variable": "t", "index": {"time": 9}}), json!({"path": "/nowhere/x.zarr"}), ] { assert!(s.run("layer.addZarr", &bad).is_err(), "{bad}"); } let _ = std::fs::remove_dir_all(dir); } /// A stand-in S3 endpoint: answers a listing, an object and a redirect, and keeps each request's /// head (path and headers) for the test to look at. fn serve_s3() -> (String, std::sync::Arc>>) { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let log = seen.clone(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { continue }; let mut buf = [0u8; 8192]; let read = s.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..read]).to_string(); let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); log.lock().unwrap().push(req); let (status, body): (&str, &[u8]) = if path.starts_with("/s3-test-bucket?list-type=2") { ("200 OK", br#"s3-test-bucketfalsedata/places.geojson1202024-01-01T00:00:00.000Zdata/more/"#) } else if path == "/" { ( "200 OK", br#"s3-test-bucket"#, ) } else if path.starts_with("/s3-test-bucket/data/places.geojson") { ("200 OK", br#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"n": 1}, "geometry": {"type": "Point", "coordinates": [10, 20]}}]}"#) } else if path.starts_with("/s3-test-bucket/moved.geojson") { ("301 Moved Permanently\r\nLocation: http://127.0.0.1:9/elsewhere.geojson", b"") } else { ("404 Not Found", b"NoSuchKeyThe specified key does not exist.") }; let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = s.write_all(body); } }); (base, seen) } /// S3 through a connection with credentials: listings and objects by s3:// address, signed for /// the connection's endpoint only; the secret never in a result; a signed request isn't /// redirected. #[test] fn s3_connections_listings_and_objects() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::s3_connections_listings_and_objects") { return; } let (base, seen) = serve_s3(); let mut s = Session::new(Project::default()); let r = s .run( "s3.connect", &json!({"name": "s3-test", "endpoint": base, "buckets": ["s3-test-bucket"], "accessKeyId": "AKIDTEST", "secretAccessKey": "very-secret-key", "sessionToken": "token-1"}), ) .unwrap(); assert_eq!((r["signed"].as_bool(), r["credentials"].as_str()), (Some(true), Some("keys"))); assert!(!r.to_string().contains("very-secret-key")); let listed = s.run("s3.connections", &Value::Null).unwrap(); assert!(listed.to_string().contains("s3-test") && !listed.to_string().contains("very-secret-key")); // A blank form is anonymous: never the environment's or a profile's keys unless asked for. let r = s.run("s3.connect", &json!({"name": "s3-blank", "region": ""})).unwrap(); assert_eq!( (r["signed"].as_bool(), r["credentials"].as_str(), r["region"].as_str()), (Some(false), Some("anonymous"), Some("us-east-1")) ); s.run("s3.disconnect", &json!({"name": "s3-blank"})).unwrap(); // A level of the bucket, and the connection's buckets; listing isn't an edit of the project. assert!(!s.dirty() && !s.can_undo()); let r = s.run("s3.list", &json!({"url": "s3://s3-test-bucket/data/"})).unwrap(); assert!(!s.dirty() && !s.can_undo(), "a listing is no edit"); assert_eq!(r["prefixes"], json!(["data/more/"])); assert_eq!(r["objects"][0]["url"], "s3://s3-test-bucket/data/places.geojson"); let r = s.run("s3.list", &json!({"connection": "s3-test"})).unwrap(); assert_eq!(r["buckets"], json!(["s3-test-bucket"])); // An object by its s3:// address, signed with the connection's key (never its secret). let r = s.run("layer.addUrl", &json!({"url": "s3://s3-test-bucket/data/places.geojson"})).unwrap(); assert_eq!(r["features"], 1); { let heads = seen.lock().unwrap(); let get = heads.iter().find(|h| h.contains("/s3-test-bucket/data/places.geojson")).unwrap().to_ascii_lowercase(); assert!(get.contains("authorization: aws4-hmac-sha256 credential=akidtest/"), "{get}"); assert!(get.contains("x-amz-security-token: token-1") && get.contains("x-amz-content-sha256: unsigned-payload")); assert!(heads.iter().all(|h| !h.contains("very-secret-key"))); } // A redirect isn't followed by a signed request; a missing key says so. let e = s.run("layer.addUrl", &json!({"url": "s3://s3-test-bucket/moved.geojson"})).unwrap_err(); assert!(e.contains("redirect"), "{e}"); assert!(s.run("layer.addUrl", &json!({"url": "s3://s3-test-bucket/nope.geojson"})).unwrap_err().contains("404")); // Bad parameters; credentials from this computer don't go to another endpoint. for bad in [ json!({"name": "x", "endpoint": "ftp://h"}), json!({"name": "x", "accessKeyId": "A"}), json!({"name": "x", "region": "no such"}), json!({"name": "x", "buckets": ["NO_"]}), json!({"name": "x", "endpoint": "https://elsewhere.example", "profile": "default"}), ] { assert!(s.run("s3.connect", &bad).is_err(), "{bad}"); } for bad in [json!({"url": "s3://"}), json!({"bucket": "NO_"}), json!({"connection": "nope"}), json!({"url": "https://x.org"})] { assert!(s.run("s3.list", &bad).is_err(), "{bad}"); } s.run("s3.disconnect", &json!({"name": "s3-test"})).unwrap(); assert!(s.run("s3.disconnect", &json!({"name": "s3-test"})).is_err()); // Plugins never connect (they'd choose where the user's credentials go). assert!(crate::cmd::plugins::user_only("s3.connect")); } /// A streamed layer outside its zoom range reads nothing for the view, as if hidden: it is not /// drawn there, so its features are not fetched (#412). Back in range, it reads again. #[test] fn streamed_layer_outside_its_zoom_range_reads_nothing() { use rustgis_data::{Dataset, Feature, Field, FieldType, Geometry, GeometryType, Value}; let mut ds = Dataset::new("grid", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); for i in 0..200u32 { for j in 0..200u32 { let c = rustgis_geom::Coord::new(-179.0 + 1.79 * f64::from(i), -80.0 + 0.8 * f64::from(j)); let n = u64::from(i * 200 + j); ds.features.push(Feature { id: n + 1, geometry: Some(Geometry::Point(c)), attrs: vec![Value::Int(n as i64)] }); } } let (base, sent) = serve_ranges(rustgis_data::source::fgb::write(&ds).unwrap()); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/grid.fgb")})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; // Drawn only when zoomed in beyond 1:10,000,000. s.run("layer.scaleRange", &json!({"layer": id, "min": 1e7})).unwrap(); let before = sent.load(std::sync::atomic::Ordering::Relaxed); // Zoomed out (an export, which waits for its reads): not drawn, so not read. let v = view_of(&mut s, rustgis_geom::Extent::new(-60.0, -40.0, 60.0, 40.0)); assert!(s.map().scale() > 1e7); crate::render::render_png(s.map_mut(), &v).unwrap(); let l = s.map().layer(id).unwrap(); assert!(l.dataset.features.is_empty() && l.source.as_ref().unwrap().window().is_none()); assert_eq!(sent.load(std::sync::atomic::Ordering::Relaxed), before); // Zoomed in, inside the range: read and drawn. let v = view_of(&mut s, rustgis_geom::Extent::new(10.0, 10.0, 12.0, 11.0)); assert!(s.map().scale() < 1e7); crate::render::render_png(s.map_mut(), &v).unwrap(); let l = s.map().layer(id).unwrap(); assert!(!l.dataset.features.is_empty(), "{:?}", l.source.as_ref().unwrap().error); assert!(sent.load(std::sync::atomic::Ordering::Relaxed) > before); } /// Load All Features on a layer whose web source is still opening waits for it to open, rather /// than fail with "the layer's source isn't open" (the server here holds its answers a while). #[test] fn load_all_features_waits_for_a_source_still_opening() { use rustgis_data::{Dataset, Feature, Geometry, GeometryType}; let mut ds = Dataset::new("pts", GeometryType::Point, vec![]); for i in 0..100u32 { let c = rustgis_geom::Coord::new(f64::from(i) * 0.1, 1.0); ds.features.push(Feature { id: u64::from(i) + 1, geometry: Some(Geometry::Point(c)), attrs: vec![] }); } let fgb = rustgis_data::source::fgb::write(&ds).unwrap(); let (base, _) = serve_ranges(fgb.clone()); let mut s = Session::new(Project::default()); s.run("layer.addUrl", &json!({"url": format!("{base}/pts.fgb")})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let (slow, _) = serve_gated(fgb, true, Some(gate.clone())); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text.replace(&base, &slow)})).unwrap(); let layer = t.map().layers.iter().find(|l| l.is_streamed()).unwrap().clone(); // Started as the view starts it, and held by the server: still opening when the load begins. let mut started = layer.source.clone().unwrap(); started.open().unwrap(); assert!(!started.is_open() && started.is_pending()); let load = std::thread::spawn(move || layer.load_all()); // Still waiting while the server holds its answers, rather than failed at once. std::thread::sleep(std::time::Duration::from_millis(300)); assert!(!load.is_finished(), "the load didn't wait for the source: {:?}", load.join().unwrap().err()); gate.store(true, std::sync::atomic::Ordering::Relaxed); assert_eq!(load.join().unwrap().unwrap().features.len(), 100); } /// A FlatGeobuf and a GeoParquet by their address are read in place, by range: a view fetches a /// small part of the file; the project keeps the address and reads it again; asked to, a file is /// downloaded whole instead. #[test] fn flatgeobuf_and_geoparquet_by_address_are_read_in_place() { use rustgis_data::{Dataset, Feature, Field, FieldType, Geometry, GeometryType, Value}; let mut ds = Dataset::new("grid", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); for i in 0..200u32 { for j in 0..200u32 { let c = rustgis_geom::Coord::new(-179.0 + 1.79 * f64::from(i), -80.0 + 0.8 * f64::from(j)); let n = u64::from(i * 200 + j); ds.features.push(Feature { id: n + 1, geometry: Some(Geometry::Point(c)), attrs: vec![Value::Int(n as i64)] }); } } let fgb = rustgis_data::source::fgb::write(&ds).unwrap(); let total = fgb.len(); let (base, sent) = serve_ranges(fgb.clone()); let mut s = Session::new(Project::default()); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/grid.fgb")})).unwrap(); assert_eq!((r["inPlace"].as_bool(), r["streamed"].as_bool(), r["features"].as_u64()), (Some(true), Some(true), Some(40_000)), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; // A view: read as the screen reads, in the background. let view = rustgis_geom::Extent::new(10.0, 10.0, 14.0, 13.0); let sync = |s: &mut Session| { let scale = s.map().scale(); let mut pending = s.map_mut().sync_sources(&view, scale, false); let start = std::time::Instant::now(); while pending && start.elapsed() < std::time::Duration::from_secs(30) { std::thread::sleep(std::time::Duration::from_millis(5)); pending = s.map_mut().sync_sources(&view, scale, false); } assert!(!pending); }; sync(&mut s); let l = s.map().layer(id).unwrap(); let w = l.source.as_ref().unwrap().window().unwrap().extent; assert!(!l.dataset.features.is_empty(), "{:?}", l.source.as_ref().unwrap().error); assert!(l.dataset.features.iter().all(|f| w.contains(f.geometry.as_ref().unwrap().extent().center()))); let read = sent.load(std::sync::atomic::Ordering::Relaxed); assert!(read < total / 4, "{read} of {total} bytes read"); // The project keeps the address, and reads it again. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.contains("grid.fgb") && !text.contains("embedded")); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); sync(&mut t); let again = t.map().layers.iter().find(|l| l.is_streamed()).unwrap(); assert!(!again.dataset.features.is_empty(), "{:?}", again.source.as_ref().unwrap().error); // Load All Features: every feature, fetched. assert_eq!(again.load_all().unwrap().features.len(), 40_000); // A project layer whose file can't be read in place (here a server that ignores ranges; a // GeoParquet without a bbox covering alike) is downloaded whole, not left empty, and keeps its // symbology; the project still keeps the address. let (plain, _) = serve_file(fgb.clone(), false); let mut w = Session::new(Project::default()); w.run("project.openText", &json!({"text": text.replace(&base, &plain)})).unwrap(); let renderer = w.map().layers.iter().find(|l| l.is_streamed()).unwrap().renderer.clone(); sync(&mut w); let whole = w.map().layers.iter().find(|l| l.is_streamed()).unwrap(); assert!(!whole.dataset.features.is_empty(), "{:?}", whole.source.as_ref().unwrap().error); assert_eq!(whole.renderer, renderer); assert!(serde_json::to_string(&w.project).unwrap().contains(&plain)); // A project written by another app may list no fields for a streamed layer: it gets the // source's, so its features keep their attributes (and a renderer on a field finds it). let mut bare: serde_json::Value = serde_json::from_str(&text).unwrap(); for l in bare["maps"][0]["layers"].as_array_mut().unwrap() { l["dataset"]["fields"] = json!([]); } let mut v = Session::new(Project::default()); v.run("project.openText", &json!({"text": bare.to_string()})).unwrap(); // Load All Features before the layer has drawn keeps them too. let all = v.map().layers.iter().find(|l| l.is_streamed()).unwrap().load_all().unwrap(); assert_eq!(all.fields.iter().map(|f| f.name.as_str()).collect::>(), ["n"]); sync(&mut v); let l = v.map().layers.iter().find(|l| l.is_streamed()).unwrap(); assert_eq!(l.dataset.fields.iter().map(|f| f.name.as_str()).collect::>(), ["n"]); // The values themselves, not nulls padded in for a missing field. assert!(!l.dataset.features.is_empty()); assert!(l.dataset.features.iter().all(|f| f.attrs.len() == 1 && matches!(f.attrs[0], Value::Int(_)))); // Copies of a layer still opening (undo snapshots, Load All Features) all open: they share // what opening brought, none takes it from the others. // The server holds its answers, so the copies are made while the header is still on its way. let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let (other, _) = serve_gated(fgb.clone(), true, Some(gate.clone())); let mut u = Session::new(Project::default()); u.run("project.openText", &json!({"text": text.replace(&base, &other)})).unwrap(); let mut a = u.map().layers.iter().find(|l| l.is_streamed()).unwrap().source.clone().unwrap(); a.open().unwrap(); assert!(!a.is_open() && a.is_pending(), "opened before its header was fetched"); let mut b = a.clone(); gate.store(true, std::sync::atomic::Ordering::Relaxed); a.wait_open(std::time::Duration::from_secs(60)); b.wait_open(std::time::Duration::from_secs(60)); assert!(a.is_open() && b.is_open(), "{:?} {:?}", a.error, b.error); // A GeoParquet (with its bbox covering), from its footer at the end. let pq = rustgis_data::source::parquet::write(&ds).unwrap(); let (base, _) = serve_ranges(pq); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/grid.parquet"), "name": "PQ"})).unwrap(); assert_eq!(r["inPlace"].as_bool(), Some(true), "{r}"); let id = r["layer"].as_u64().unwrap() as u32; sync(&mut s); let l = s.map().layer(id).unwrap(); assert!(!l.dataset.features.is_empty(), "{:?}", l.source.as_ref().unwrap().error); assert_eq!(l.name, "PQ"); // Asked to: downloaded whole. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/grid.parquet"), "whole": true, "name": "Whole"})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); // A server that ignores ranges answers the footer's request with the whole, longer file: // downloaded whole instead, not an error. let mut pq = rustgis_data::source::parquet::write(&ds).unwrap(); // Pad before the footer so compression cannot shrink this oversized-response fixture below // the initial footer request. Column offsets and the footer's metadata length stay valid. let footer_len = 8 + u32::from_le_bytes(pq[pq.len() - 8..pq.len() - 4].try_into().unwrap()) as usize; let footer = pq.split_off(pq.len() - footer_len); pq.resize(pq.len().max(256 << 10), 0); pq.extend(footer); assert!(pq.len() > 256 << 10); let (base, _) = serve_file(pq, false); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/grid.parquet"), "name": "NoRanges"})).unwrap(); assert!(r.get("inPlace").is_none(), "{r}"); assert!(s.map().layer_by_name("NoRanges").is_some(), "{r}"); // A file that isn't one: an error, not a hang. let (base, _) = serve_ranges(b"not a flatgeobuf at all".to_vec()); assert!(s.run("layer.addUrl", &json!({"url": format!("{base}/x.fgb")})).is_err()); } /// Data sources: what was added lands in Recent (once, newest first); favourites and saved /// sources are kept and removed by command; nothing kept holds a secret; the library lists /// well-known public sources. #[test] fn data_sources_recent_favourites_and_saved() { let dir = std::env::temp_dir().join(format!("rustgis-sources-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("towns.geojson"); std::fs::write( &path, r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[1,2]}}]}"#, ) .unwrap(); let mut s = Session::new(Project::default()); let p = path.display().to_string(); s.run("layer.addData", &json!({"path": p})).unwrap(); s.run("layer.addSample", &json!({"name": "cities"})).unwrap(); // A failed add isn't a source. assert!(s.run("layer.addData", &json!({"path": dir.join("missing.geojson").display().to_string()})).is_err()); let l = s.run("sources.list", &Value::Null).unwrap(); let recent: Vec<&str> = l["recent"].as_array().unwrap().iter().filter_map(|r| r["address"].as_str()).collect(); assert_eq!(recent, ["cities", p.as_str()]); assert_eq!(l["recent"][1]["name"], "towns"); assert_eq!(l["recent"][1]["addWith"], "layer.addData {path}"); assert!(l["library"].as_array().unwrap().iter().any(|x| x["group"] == "Catalogs" && x["kind"] == "stac")); // Favourites and saved services, kept without their secrets. s.run("sources.favourite", &json!({"kind": "file", "address": p})).unwrap(); let r = s.run("sources.save", &json!({"kind": "service", "address": "https://me:pw@gis.example/wms?token=abc&map=m", "name": "My WMS", "extra": {"type": "wms", "password": "x"}})).unwrap(); assert_eq!(r["address"], "https://gis.example/wms?map=m"); s.run("sources.save", &json!({"kind": "database", "address": "postgres:host=h dbname=g user=u password=secret"})).unwrap(); let text = s.sources.to_json(); for secret in ["pw", "abc", "secret", "\"x\""] { assert!(!text.contains(secret), "{secret} in {text}"); } // The app keeps them in its preferences and reads them back. let back = rustgis_engine_sources_roundtrip(&text); assert_eq!((back.favourites.len(), back.saved.len(), back.recent.len()), (1, 2, 2)); // Removed by list, kind and address; an unknown one is an error. s.run("sources.favourite", &json!({"kind": "file", "address": p, "on": false})).unwrap(); assert!(s.sources.favourites.is_empty()); s.run("sources.remove", &json!({"list": "saved", "kind": "service", "address": "https://gis.example/wms?map=m"})).unwrap(); assert_eq!(s.sources.saved.len(), 1); for bad in [ json!({"list": "saved", "kind": "service", "address": "https://nowhere"}), json!({"list": "elsewhere", "kind": "file", "address": "x"}), json!({"kind": "nope", "address": "x"}), ] { assert!(s.run("sources.remove", &bad).is_err(), "{bad}"); } assert!(s.run("sources.save", &json!({"kind": "nope", "address": "x"})).is_err()); let _ = std::fs::remove_dir_all(dir); } #[test] fn layer_address_redaction_preserves_templates_and_local_paths() { let cases = [ ("https://{switch:a,b}.host/{z}/{x}/{y}@2x.png?key=secret&map=m#token", "https://{switch:a,b}.host/{z}/{x}/{y}@2x.png?map=m"), ("HTTPS://u:p@host/data?token=secret&f=json", "HTTPS://host/data?f=json"), ("s3://u:p@bucket/data?X-Amz-Signature=secret", "s3://bucket/data"), ("/tmp/data?key=not-a-web-secret", "/tmp/data?key=not-a-web-secret"), ("upload:map?token=local", "upload:map?token=local"), ("postgres:host=localhost dbname=gis", "postgres:host=localhost dbname=gis"), ("postgresql://user@host.invalid/db%40tag", "postgresql://user@host.invalid/db%40tag"), ]; for (original, expected) in cases { let address = match crate::durable::public_address(original) { crate::durable::Kept::Unchanged => original.to_owned(), crate::durable::Kept::Changed(address) => address, crate::durable::Kept::Withheld(_) => panic!("valid address was withheld: {original}"), }; assert_eq!(address, expected); } let original = "postgresql://user:p@ss/word@host.invalid/db"; let mut s = world(); s.map_mut().layers[0].dataset.source = Some(original.into()); let (text, report) = s.project.saved_text(None).unwrap(); let saved: Value = serde_json::from_str(&text).unwrap(); assert_eq!(saved["maps"][0]["layers"][0]["dataset"]["source"], "postgresql:"); assert_eq!(report.withheld, 1, "ambiguous connection output is counted instead of retargeted"); assert_eq!(s.map().layers[0].dataset.source.as_deref(), Some(original), "the live connection is unchanged"); let prefix = crate::render::rustgis_tiles::s3::ANONYMOUS; let crate::durable::Kept::Changed(anonymous) = crate::durable::public_address(&format!("{prefix}https://bucket.example/a?token=secret")) else { panic!("anonymous address was not redacted") }; assert_eq!(anonymous, format!("{prefix}https://bucket.example/a")); } /// A COG's look isn't a key: it stays in a saved address. The address's keys still go, and so does /// any other fragment (OAuth and SAS links carry tokens there), however much it resembles a look. #[test] fn layer_address_redaction_keeps_a_cogs_look() { let prefix = crate::render::rustgis_tiles::s3::ANONYMOUS; let bound = rustgis_data::login_address::bind("0123456789abcdef", "https://h.example", "https://h.example/a.tif#range=0,1000").unwrap(); let cases = [ // Nothing to take out: no change at all. ("https://h.example/a.tif#range=0,1000".to_string(), "https://h.example/a.tif#range=0,1000".to_string()), ( "s3://bucket/a.tif#bands=2,3,4&range=-0.5,0.5&dequantize=aef".into(), "s3://bucket/a.tif#bands=2,3,4&range=-0.5,0.5&dequantize=aef".into(), ), (format!("{prefix}https://h.example/a.tif#bands=1"), format!("{prefix}https://h.example/a.tif#bands=1")), // Its keys go, its look stays. ("https://u:p@h.example/a.tif?token=s3cret&v=1#range=0,1000".into(), "https://h.example/a.tif?v=1#range=0,1000".into()), ("https://u:p@h.example#range=0,1000".into(), "https://h.example#range=0,1000".into()), (format!("{prefix}https://h.example/a.tif?token=s3cret#bands=1"), format!("{prefix}https://h.example/a.tif#bands=1")), (bound, "https://h.example/a.tif#range=0,1000".into()), // Not a look: it goes as before. ("https://h.example/a.tif#access_token=s3cret".into(), "https://h.example/a.tif".into()), ("https://h.example/a.tif#range=0,1000&token=s3cret".into(), "https://h.example/a.tif".into()), ("https://h.example/a.tif#dequantize=s3cret".into(), "https://h.example/a.tif".into()), ("https://h.example/a.tif#range=0,1000?token=s3cret".into(), "https://h.example/a.tif".into()), ("https://h.example/a.tif#dequantize=aef?token=s3cret".into(), "https://h.example/a.tif".into()), // A word `dequantize` doesn't have: a reader takes it as a look, a saved file doesn't. ("https://h.example/a.tif#range=0,1000&dequantize=s3cret".into(), "https://h.example/a.tif".into()), ("cog:{z}/{x}/{y}@https://h.example/a.tif#dequantize=s3cret".into(), "cog:{z}/{x}/{y}@https://h.example/a.tif".into()), ( rustgis_data::s3_address::bind("", "s3.example.org", "https://h.example/a.tif#dequantize=s3cret").unwrap(), rustgis_data::s3_address::bind("", "s3.example.org", "https://h.example/a.tif").unwrap(), ), // Its own words stay. ("https://h.example/a.tif#range=0,1000&dequantize=none".into(), "https://h.example/a.tif#range=0,1000&dequantize=none".into()), ("cog:{z}/{x}/{y}@https://h.example/a.tif#dequantize=aef".into(), "cog:{z}/{x}/{y}@https://h.example/a.tif#dequantize=aef".into()), ("https://h.example/a.tif#range=0,1000&cube=1".into(), "https://h.example/a.tif#range=0,1000&cube=1".into()), ("https://h.example/a.tif#cube=s3cret".into(), "https://h.example/a.tif".into()), // A look after another fragment: only the look stays. ("https://h.example/a.tif#sig=s3cret#range=0,1000".into(), "https://h.example/a.tif#range=0,1000".into()), ]; for (given, expected) in &cases { let address = match crate::durable::public_address(given) { crate::durable::Kept::Unchanged => given.clone(), crate::durable::Kept::Changed(address) => address, crate::durable::Kept::Withheld(_) => panic!("valid address was withheld: {given}"), }; assert_eq!(&address, expected, "from {given}"); } } /// A data cube saved with one range for all its dates opens with that range, and saving it says /// nothing about keys. A key among its addresses goes and is counted, and only that one. #[test] fn saved_cubes_keep_their_look() { let mut s = Session::new(Project::default()); let mut cube = rustgis_map::TileLayer::new("Cube", rustgis_map::TileKind::Cog, "https://h.example/2021.tif#range=0,1000"); cube.time_urls = vec![(1.0, "https://h.example/2020.tif#range=0,1000".into()), (2.0, "https://h.example/2021.tif#range=0,1000".into())]; let expected = (cube.url.clone(), cube.time_urls.clone()); s.map_mut().tiles.push(cube); let dir = std::env::temp_dir().join(format!("rustgis-cube-look-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("cube.rgis"); // Clean: nothing was taken out, so nothing is said. let saved = s.run("project.saveAs", &json!({"path": path})).unwrap(); assert_eq!(saved["stripped"], 0, "{saved}"); assert!(!s.notices.iter().any(|n| n.text.contains("saved without their keys"))); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path})).unwrap(); let opened = &t.map().tiles[0]; assert_eq!((opened.url.clone(), opened.time_urls.clone()), expected); // Lossy: one date's token goes and is counted; its look stays; the open project keeps it all. let with_key = "https://h.example/2020.tif?token=s3cret#range=0,1000"; s.map_mut().tiles[0].time_urls[0].1 = with_key.into(); let saved = s.run("project.saveAs", &json!({"path": path})).unwrap(); assert_eq!(saved["stripped"], 1, "{saved}"); assert!(s.notices.iter().any(|n| n.text.contains("1 layer address(es) were saved without their keys"))); assert_eq!(s.map().tiles[0].time_urls[0].1, with_key); let text = std::fs::read_to_string(&path).unwrap(); assert!(!text.contains("s3cret") && text.contains("https://h.example/2020.tif#range=0,1000"), "{text}"); // Opened again before RustGIS closes, it has its token back, with its look. t.run("project.open", &json!({"path": path})).unwrap(); assert_eq!(t.map().tiles[0].time_urls[0].1, with_key); // A cube of one file: its address is all that keeps it a tile layer when its project opens. let one_file = "https://h.example/2020.tif#range=0,1000&cube=1"; let mut one = Session::new(Project::default()); one.map_mut().tiles.push(rustgis_map::TileLayer::new("One date", rustgis_map::TileKind::Cog, one_file)); let saved = one.run("project.saveAs", &json!({"path": path})).unwrap(); assert_eq!(saved["stripped"], 0, "{saved}"); t.run("project.open", &json!({"path": path})).unwrap(); assert_eq!(t.map().tiles[0].url, one_file); let _ = std::fs::remove_dir_all(dir); } #[test] fn saved_layer_files_keep_no_address_secrets() { let mut s = world(); let feature = s.map().layers[0].id; let dataset_url = "https://user:dataset-password@gis.example/features?access_token=dataset-token&f=json"; let source_url = "https://gis.example/roads.fgb?sig=source-signature&sv=1&collection=roads"; { let layer = s.map_mut().layer_mut(feature).unwrap(); layer.dataset.source = Some(dataset_url.into()); layer.source = Some(serde_json::from_value(json!({"path":source_url})).unwrap()); } let added = s.run("layer.addDataBytes", &json!({"fileName":"dem.tif","name":"Protected raster","data":dem_b64()})).unwrap(); let raster = added["layer"].as_u64().unwrap() as u32; let raster_url = "https://user:raster-password@gis.example/dem.tif?api_key=raster-key&band=1"; let zarr_url = "https://gis.example/air.zarr?token=zarr-token&variable=air"; { let layer = s.map_mut().layer_mut(raster).unwrap().raster.as_mut().unwrap(); layer.path = Some(raster_url.into()); layer.time_zarr = Some(rustgis_map::raster::ZarrTime { store: zarr_url.into(), ..Default::default() }); } for id in [feature, raster] { let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "layer", &json!({"layer":id})).unwrap(); let exported: Value = serde_json::from_slice(&bytes).unwrap(); if id == feature { assert_eq!(exported["dataset"]["source"], "https://gis.example/features?f=json"); assert_eq!(exported["source"]["path"], "https://gis.example/roads.fgb?collection=roads"); } else { assert_eq!(exported["raster"]["path"], "https://gis.example/dem.tif?band=1"); assert_eq!(exported["raster"]["time_zarr"]["store"], "https://gis.example/air.zarr?variable=air"); } } let layer = s.map().layer(feature).unwrap(); assert_eq!(layer.dataset.source.as_deref(), Some(dataset_url)); assert_eq!(layer.source.as_ref().unwrap().path.as_deref(), Some(source_url)); let layer = s.map().layer(raster).unwrap().raster.as_ref().unwrap(); assert_eq!(layer.path.as_deref(), Some(raster_url)); assert_eq!(layer.time_zarr.as_ref().unwrap().store, zarr_url); } #[cfg(not(target_arch = "wasm32"))] #[test] fn database_authorization_failures_do_not_wait_for_password() { use std::io::{Read, Write}; for (name, message) in [("HBA blocked", "no pg_hba.conf entry for host"), ("Missing role", "role \"missing\" does not exist")] { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut length = [0; 4]; stream.read_exact(&mut length).unwrap(); let n = u32::from_be_bytes(length); assert!((4..=65536).contains(&n)); stream.read_exact(&mut vec![0; (n - 4) as usize]).unwrap(); let body = format!("SFATAL\0VFATAL\0C28000\0M{message}\0\0"); stream.write_all(b"E").unwrap(); stream.write_all(&((body.len() + 4) as u32).to_be_bytes()).unwrap(); stream.write_all(body.as_bytes()).unwrap(); }); let dsn = format!("postgres:host=127.0.0.1 port={port} dbname=review_auth user=missing sslmode=disable"); let mut project = world().project; project.map_mut().layers[0].source = Some(serde_json::from_value(json!({"postgis":{"database":dsn,"table":"roads","family":"Lines"}})).unwrap()); let mut s = Session::new(Project::default()); s.run("connection.new", &json!({"kind":"postgres","name":name,"address":dsn})).unwrap(); s.run("project.openText", &json!({"text":serde_json::to_string(&project).unwrap()})).unwrap(); server.join().unwrap(); let source = s.map().layers[0].source.as_ref().unwrap(); assert!(source.error.as_deref().unwrap().contains(message)); assert!(!source.login, "{message} cannot be fixed by a password"); assert!(!s.notices.iter().any(|n| n.text.contains(name))); assert!(s.run("connection.list", &Value::Null).unwrap()["connections"][0].get("waiting").is_none()); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn database_layers_wait_for_their_connection_login() { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { for mut stream in listener.incoming().take(16).flatten() { stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut length = [0; 4]; if stream.read_exact(&mut length).is_err() { continue; } let n = u32::from_be_bytes(length); if !(4..=65536).contains(&n) { continue; } let mut startup = vec![0; (n - 4) as usize]; if stream.read_exact(&mut startup).is_err() || stream.write_all(b"R\0\0\0\x08\0\0\0\x03").is_err() { continue; } let mut kind = [0]; if stream.read_exact(&mut kind).is_err() || kind[0] != b'p' || stream.read_exact(&mut length).is_err() { continue; } let n = u32::from_be_bytes(length); if !(4..=65536).contains(&n) { continue; } let mut password = vec![0; (n - 4) as usize]; if stream.read_exact(&mut password).is_err() { continue; } let body = b"SFATAL\0VFATAL\0C28P01\0Mpassword authentication failed for user \"me\"\0\0"; let _ = stream.write_all(b"E"); let _ = stream.write_all(&((body.len() + 4) as u32).to_be_bytes()); let _ = stream.write_all(body); } }); let dsn = format!("postgres:host=127.0.0.1 port={port} dbname=waiting_login user=me sslmode=disable"); let down = "postgres:host=127.0.0.1 port=1 dbname=waiting_down user=me sslmode=disable"; let mut s = world(); for (layer, database) in s.map_mut().layers.iter_mut().zip([dsn.as_str(), dsn.as_str(), down]) { layer.source = Some(serde_json::from_value(json!({"postgis":{"database":database,"table":"roads","family":"Lines"}})).unwrap()); } let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("connection.new", &json!({"kind":"postgres","name":"Production GIS","address":dsn})).unwrap(); t.run("connection.new", &json!({"kind":"postgres","name":"Down","address":down})).unwrap(); t.run("project.openText", &json!({"text":text})).unwrap(); for layer in t.map().layers.iter().take(2) { // No password has been given: this exercises tokio-postgres's client-side config error, // before the later login attempts exercise the server's 28P01 response. assert!(layer.source.as_ref().unwrap().error.as_deref().unwrap().contains("password missing")); assert!(layer.source.as_ref().unwrap().login); assert_eq!(t.connections.for_layer(layer).unwrap().name, "Production GIS"); } assert!(t.map().layers.iter().skip(3).all(|l| t.connections.for_layer(l).is_none())); let third = t.map().layers[2].source.as_ref().unwrap(); assert!(third.error.is_some() && !third.login); assert!(t.notices.iter().any(|n| n.text.contains("2 layer(s)") && n.text.contains("Production GIS"))); assert!(!t.notices.iter().any(|n| n.text.contains("Down"))); let listed = t.run("connection.list", &Value::Null).unwrap(); assert_eq!(listed["connections"][0]["waiting"], 2); assert!(listed["connections"][1].get("waiting").is_none()); let result = t.run("connection.login", &json!({"id":"Production GIS","password":"wrong"})).unwrap(); assert_eq!(result["reopened"], 0); assert_eq!(result["failed"], 2); assert!(t.map().layers.iter().take(2).all(|l| l.source.as_ref().unwrap().login)); assert_eq!(t.run("connection.list", &Value::Null).unwrap()["connections"][0]["waiting"], 2); // Duplicate addresses have no inferred connection identity; either can be selected explicitly. t.run("connection.new", &json!({"kind":"postgres","name":"Same database","address":dsn})).unwrap(); assert!(t.connections.for_layer(&t.map().layers[0]).is_none()); let result = t.run("connection.login", &json!({"id":"Same database","password":"wrong"})).unwrap(); assert_eq!(result["failed"], 2); } /// An autosaved recovery copy (#873) leaves out layer secrets as a save does, from a copy of the /// project (the open one keeps them), and isn't registered as a file this process wrote: frequent /// copies don't evict saved snapshots, and opening a copy's text restores no secret. #[test] fn recovery_copies_keep_no_layer_tokens_and_restore_none() { let mut s = world(); let url = "https://gis.example/arcgis/rest/services/a/MapServer/tile/{z}/{y}/{x}?token=s3cret"; s.run("map.addTiles", &json!({"url":url,"name":"Secured"})).unwrap(); let text = s.project.clone().recovery_text().unwrap(); assert!(!text.contains("s3cret") && text.contains("MapServer/tile/{z}/{y}/{x}")); assert!(s.map().tiles.iter().any(|t| t.url == url), "the open project keeps its token"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text":text})).unwrap(); assert!(t.map().tiles.iter().any(|t| t.url.contains("MapServer/tile"))); assert!(!serde_json::to_string(&t.project).unwrap().contains("s3cret")); } #[test] fn saved_projects_keep_no_layer_tokens() { let mut s = world(); let url = "https://gis.example/arcgis/rest/services/a/MapServer/tile/{z}/{y}/{x}?token=s3cret-tile"; // Its legend, built from the service's address, with the key in it too. let legend = "https://gis.example/wms?key=l3g-secret&SERVICE=WMS&REQUEST=GetLegendGraphic&LAYER=a"; s.run("map.addTiles", &json!({"url":url,"name":"Secured","legend":legend})).unwrap(); let layer = s.map().layers[0].id; s.map_mut().layers[0].dataset.source = Some("https://u:pw-secret@gis.example/f?f=json&access_token=tok2-secret".into()); s.map_mut().service_groups.push(rustgis_map::ServiceGroup { id: 123, name: "Protected service".into(), url: "https://gis.example/wfs?key=k3y-secret&map=m".into(), kind: "wfs".into(), anonymous: false, expanded: true, children: vec![rustgis_map::ServiceChild { layer, collection: "roads".into(), source_url: "https://gis.example/roads?sig=x-secret&sv=1".into(), }], }); let path = std::env::temp_dir().join(format!("rustgis-token-audit-{}.rgis", std::process::id())); let result = s.run("project.saveAs", &json!({"path":path})).unwrap(); let text = std::fs::read_to_string(&path).unwrap(); for secret in ["s3cret-tile", "pw-secret@", "tok2-secret", "k3y-secret", "sig=x-secret", "l3g-secret"] { assert!(!text.contains(secret), "{secret} leaked into the file"); } assert!(text.contains("MapServer/tile/{z}/{y}/{x}") && text.contains("map=m") && text.contains("REQUEST=GetLegendGraphic")); assert!(result["stripped"].as_u64().unwrap() >= 4); assert!(s.notices.iter().any(|n| n.text.contains("saved without their keys"))); assert!(s.map().tiles.iter().any(|t| t.url == url)); // Every kind of item file, each with something in the project to write. s.run("style.new", &json!({"layer": "Cities", "name": "Cities"})).unwrap(); s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 100}})).unwrap(); s.run("task.new", &Value::Null).unwrap(); s.run("model.new", &json!({"fromHistory": true})).unwrap(); s.run("report.new", &json!({"layer": "Cities"})).unwrap(); s.run("presentation.new", &Value::Null).unwrap(); s.run("notebook.new", &Value::Null).unwrap(); // Kinds that hold no layer addresses, or need more than the sample gives. let exempt = [ ("ramps", "colour ramps hold no addresses"), ("dlPackage", "a trained model holds no addresses"), ("hostedImagery", "tiles rendered from a raster layer: pictures, not addresses"), ("linkChart", "needs relationships the sample lacks"), ]; let mut report = Vec::new(); for &(kind, _, _) in crate::cmd::items::ITEMS { if exempt.iter().any(|(k, _)| *k == kind) { continue; } let bytes = match crate::cmd::items::item_bytes(&mut s, kind, &Value::Null) { Ok((_, bytes)) => bytes, Err(e) => { report.push(format!("{kind}: {e}")); continue; } }; let packaged = if bytes.starts_with(b"PK") { rustgis_data::zip::read_matching(&bytes, |_| true) .unwrap() .into_iter() .map(|(_, b)| String::from_utf8_lossy(&b).into_owned()) .collect::() } else { String::from_utf8_lossy(&bytes).into_owned() }; for secret in ["s3cret-tile", "pw-secret@", "tok2-secret", "k3y-secret", "sig=x-secret", "l3g-secret"] { if packaged.contains(secret) { report.push(format!("{kind}: leaked {secret}")); } } if kind == "projectPackage" { // Even a hostile archive carrying the exact registered JSON must not restore it. let hostile = rustgis_data::zip::write(&[("exact.rgis".into(), text.as_bytes().to_vec())]).unwrap(); let package_path = path.with_extension("rgispkg"); std::fs::write(&package_path, hostile).unwrap(); let mut opened = Session::new(Project::default()); opened.run("project.open", &json!({"path":package_path})).unwrap(); assert!(!serde_json::to_string(&opened.project).unwrap().contains("s3cret-tile")); opened.run("project.openText", &json!({"text":text,"package":true})).unwrap(); assert!(!serde_json::to_string(&opened.project).unwrap().contains("s3cret-tile")); std::fs::remove_file(package_path).unwrap(); } } assert!(report.is_empty(), "{report:#?}"); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path":path})).unwrap(); assert!(t.map().tiles.iter().any(|t| t.url == url)); assert!(t.map().layers[0].dataset.source.as_deref().unwrap().contains("tok2-secret")); let modified = text.replace("\"name\":\"Secured\"", "\"name\":\"Secures\""); assert_ne!(text, modified); t.run("project.openText", &json!({"text":modified})).unwrap(); let live = serde_json::to_string(&t.project).unwrap(); for secret in ["s3cret-tile", "pw-secret@", "tok2-secret", "k3y-secret", "sig=x-secret"] { assert!(!live.contains(secret), "{secret} restored into a modified hostile file"); } let public = url.split('?').next().unwrap(); let mut hand = world(); hand.run("map.addTiles", &json!({"url":public,"name":"Hand made"})).unwrap(); t.run("project.openText", &json!({"text":serde_json::to_string(&hand.project).unwrap()})).unwrap(); assert!(t.map().tiles.iter().any(|t| t.url == public)); assert!(!serde_json::to_string(&t.project).unwrap().contains("s3cret-tile")); std::fs::remove_file(path).unwrap(); } #[test] fn connections_commands() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::connections_commands") { return; } use crate::render::rustgis_tiles::s3; let mut s = Session::new(Project::default()); let a = s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms?token=t","name":"A"})).unwrap(); assert_eq!(a["stripped"], true); assert_eq!(a["address"], "https://a.example/wms"); s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms","name":"B"})).unwrap(); assert!(s.run("connection.new", &json!({"kind":"wms","address":"https://b.example/wms","name":"a"})).is_err()); s.run("connection.edit", &json!({"id":"A","name":"C","group":"Work"})).unwrap(); let listed = s.run("connection.list", &json!({"group":"work"})).unwrap(); assert_eq!(listed["connections"][0]["id"], a["id"]); assert_eq!(listed["connections"][0]["name"], "C"); let copy = s.run("connection.duplicate", &json!({"id":"C"})).unwrap(); assert_eq!(copy["name"], "C (2)"); s.run("connection.remove", &json!({"id":copy["id"]})).unwrap(); assert!(s.connections.find(copy["id"].as_str().unwrap()).is_none()); assert!(s.run("connection.edit", &json!({"id":"C","kind":"wfs"})).is_err()); assert!(s.run("connection.edit", &json!({"id":"C","name":"b"})).is_err()); assert!(s.run("connection.new", &json!({"kind":"s3","options":{"region":[1]}})).is_err()); #[cfg(not(target_arch = "wasm32"))] { let db = s .run( "connection.new", &json!({"kind":"postgres","address":"postgres:host=127.0.0.1 port=9 dbname=connections_commands user=u"}), ) .unwrap(); s.run("connection.login", &json!({"id":db["id"],"password":"pw"})).unwrap(); let list = s.run("connection.list", &json!({"kind":"postgres"})).unwrap(); assert_eq!(list["connections"][0]["loggedIn"], true); assert!(!s.connections.to_json().contains("pw")); let copy = s.run("connection.duplicate", &json!({"id":db["id"]})).unwrap(); assert_eq!(copy["loggedIn"], false, "a duplicate must not inherit another saved ID's session login"); let edited = s .run("connection.edit", &json!({"id":db["id"],"address":"postgres:host=127.0.0.1 port=9 dbname=connections_other user=u"})) .unwrap(); assert_eq!(edited["loggedIn"], false, "a different endpoint must not inherit the old password"); s.run("connection.remove", &json!({"id":db["id"]})).unwrap(); let list = s.run("connection.list", &json!({"kind":"postgres"})).unwrap(); assert_eq!(list["connections"][0]["id"], copy["id"]); assert_eq!(list["connections"][0]["loggedIn"], false); } s.run("connection.new", &json!({"kind":"s3","name":"conn-test-s3","options":{"credentials":"keys","buckets":"b11"}})).unwrap(); // A saved connection that uses keys reads them from the credential store (this session has none). assert!(s.run("connection.test", &json!({"id":"conn-test-s3"})).unwrap_err().contains("Saved S3 keys are unavailable")); s.run("connection.login", &json!({"id":"conn-test-s3","accessKeyId":"AKIDTEST","secretAccessKey":"very-secret-key"})).unwrap(); assert!(s3::connection("conn-test-s3").is_some()); assert!(!s.connections.to_json().contains("very-secret")); s.run("connection.edit", &json!({"id":"conn-test-s3","address":"https://other.example"})).unwrap(); assert!(s3::connection("conn-test-s3").is_none()); s.run("connection.new", &json!({"kind":"wms","name":"Closed","address":"http://127.0.0.1:9/wms"})).unwrap(); assert!(s.run("connection.test", &json!({"id":"Closed"})).unwrap_err().contains("127.0.0.1:9")); // A service without a login method logs in to nothing: it is only chosen for its address. assert_eq!(s.run("connection.login", &json!({"id":"Closed"})).unwrap()["loggedIn"], false); assert!(s.run("connection.login", &json!({"id":"Closed","apiKey":"k"})).unwrap_err().contains("no login method")); s.run("connection.edit", &json!({"id":"Closed","kind":" wms "})).unwrap(); let default = s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms"})).unwrap(); let renamed = s.run("connection.edit", &json!({"id":"B","name":""})).unwrap(); assert_eq!(renamed["name"], "wms (2)"); assert_ne!(default["id"], renamed["id"]); assert!(crate::sources::holds_secret(&json!({"id":"x","password":"p"}))); for id in [ "connection.new", "connection.edit", "connection.duplicate", "connection.remove", "connection.login", "connection.forgetPassword", "connection.test", "connection.browse", ] { assert!(crate::cmd::plugins::user_only(id), "{id}"); } assert!(!crate::cmd::plugins::user_only("connection.list")); assert!(!s.dirty() && !s.can_undo()); } #[test] fn connections_loaded_names_route_login_and_remove_to_the_right_endpoint() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::connections_loaded_names_route_login_and_remove_to_the_right_endpoint") { return; } use crate::render::rustgis_tiles::s3; let mut s = Session::new(Project::default()); s.connections = crate::connections::Connections::from_json(&json!({"connections":[ {"id":"c1","kind":"s3","name":"connections-alias-target","options":{"credentials":"keys"}}, {"id":"connections-alias-target","kind":"postgres","name":"Database","address":"postgres:host=127.0.0.1 port=9 dbname=connections_alias user=u"} ]}).to_string()); let name = s.connections.find("c1").unwrap().name.clone(); assert_eq!(name, "connections-alias-target (2)"); let logged_in = s.run("connection.login", &json!({"id":name,"accessKeyId":"AKIDTEST","secretAccessKey":"alias-test-secret"})).unwrap(); assert_eq!(logged_in["id"], "c1"); assert!(s3::connection(&name).is_some()); let removed = s.run("connection.remove", &json!({"id":name})).unwrap(); assert_eq!(removed["removed"], "c1"); assert!(s3::connection(&name).is_none()); assert_eq!(s.connections.find("connections-alias-target").unwrap().name, "Database"); } #[test] fn connections_s3_rename_cannot_inherit_a_destination_session() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::connections_s3_rename_cannot_inherit_a_destination_session") { return; } use crate::render::rustgis_tiles::s3; let mut s = Session::new(Project::default()); let (old, destination, occupied) = ("rename-test-old", "rename-test-destination", "rename-test-occupied"); let saved = s .run( "connection.new", &json!({ "kind":"s3","name":old,"address":"https://saved.example","options":{"credentials":"keys"} }), ) .unwrap(); s.run("connection.login", &json!({"id":old,"accessKeyId":"SAVEDKEY","secretAccessKey":"saved-test-secret"})).unwrap(); s.run( "s3.connect", &json!({ "name":destination,"endpoint":"https://runtime.example","accessKeyId":"RUNTIMEKEY","secretAccessKey":"runtime-test-secret" }), ) .unwrap(); s.run("connection.new", &json!({"kind":"s3","name":occupied})).unwrap(); assert!(s.run("connection.edit", &json!({"id":old,"name":occupied})).is_err()); assert!(s3::connection(old).is_some(), "a rejected edit must preserve the original login"); assert!(s3::connection(destination).is_some(), "a rejected edit must not disconnect another session"); let grouped = s.run("connection.edit", &json!({"id":old,"group":"Work"})).unwrap(); assert_eq!(grouped["loggedIn"], true, "a group-only edit must preserve the login"); let renamed = s.run("connection.edit", &json!({"id":old,"name":destination})).unwrap(); assert_eq!(renamed["id"], saved["id"]); assert_eq!(renamed["address"], "https://saved.example"); assert_eq!(renamed["loggedIn"], false); assert!(s3::connection(old).is_none()); assert!(s3::connection(destination).is_none()); assert!(s.run("connection.test", &json!({"id":destination})).unwrap_err().contains("Saved S3 keys are unavailable")); s.run("connection.login", &json!({"id":destination,"accessKeyId":"NEWKEY","secretAccessKey":"new-test-secret"})).unwrap(); assert_eq!(s3::connection(destination).unwrap().endpoint.as_deref(), Some("https://saved.example")); s.run("connection.remove", &json!({"id":destination})).unwrap(); } #[test] fn connection_test_probes_and_answers() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::connection_test_probes_and_answers") { return; } use crate::render::rustgis_tiles::s3; let mut s = Session::new(Project::default()); let mut wms = json!({"kind":"wms","address":"https://a.example/wms","probe":true}); let v = s.run("connection.test", &wms).unwrap(); let fetch = v["fetch"].as_str().unwrap(); assert!(fetch.starts_with("https://a.example/wms") && fetch.contains("REQUEST=GetCapabilities")); assert!(v.get("connection").is_none()); assert!(s.connections.connections.is_empty()); wms.as_object_mut().unwrap().remove("probe"); wms["answer"] = json!( r#"Localimage/pngEPSG:3857aA"# ); assert_eq!(s.run("connection.test", &wms).unwrap()["result"]["layers"][0]["name"], "a"); assert_eq!( s.run("connection.test", &json!({"kind":"stac","address":"https://s.example/v1","probe":true})).unwrap()["fetch"], "https://s.example/v1/collections" ); assert_eq!( s.run( "connection.test", &json!({"kind":"stac","address":"https://s.example/v1","answer":r#"{"collections":[{"id":"c1","title":"C"}]}"#}) ) .unwrap()["result"]["count"], 1 ); let mut p = json!({"kind":"s3","name":"probe-s3","options":{"credentials":"none","buckets":"bkt1"},"probe":true}); assert!(s.run("connection.test", &p).unwrap()["fetch"].as_str().unwrap().contains("bkt1")); p.as_object_mut().unwrap().remove("probe"); p["answer"] = json!("a.tif1"); assert_eq!(s.run("connection.test", &p).unwrap()["result"]["count"], 1); p["options"].as_object_mut().unwrap().remove("buckets"); p["probe"] = json!(true); assert!(s.run("connection.test", &p).unwrap_err().contains("name a bucket")); s3::disconnect("probe-s3"); #[cfg(not(target_arch = "wasm32"))] assert!( s.run("connection.test", &json!({"kind":"postgres","address":"postgres:host=127.0.0.1 port=9 dbname=x user=u","probe":true})) .unwrap()["fetch"] .is_null() ); assert!(s.run("connection.test", &json!({"probe":true})).unwrap_err().contains("id, or kind and address")); assert!(s.connections.connections.is_empty()); } /// Browsing a saved connection lists what each kind offers, a level at a time, with the command /// that adds each dataset; offline, with the answers supplied. A node that isn't the /// connection's is refused, and nothing browsed is saved or undoable. #[test] fn connection_browse_levels_and_adds() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::connection_browse_levels_and_adds") { return; } use crate::render::rustgis_tiles::s3; let mut s = Session::new(Project::default()); let new = |s: &mut Session, p: Value| s.run("connection.new", &p).unwrap(); let wms_caps = r#"Localimage/pngEPSG:3857aAbB"#; // WMS: the address read, its layers, and the add that adds one. new(&mut s, json!({"kind": "wms", "address": "https://a.example/wms", "name": "W"})); let v = s.run("connection.browse", &json!({"id": "W", "probe": true})).unwrap(); assert!(v["fetch"].as_str().is_some_and(|f| f.contains("REQUEST=GetCapabilities")), "{v}"); let v = s.run("connection.browse", &json!({"id": "W", "answer": wms_caps})).unwrap(); assert_eq!(v["service"], json!({"url": "https://a.example/wms", "type": "wms"})); let first = v["children"][0].clone(); assert_eq!(first["what"], "map"); assert_eq!(first["name"], "A"); assert_eq!(first["add"]["command"], "service.add"); assert_eq!(first["add"]["params"]["url"], "https://a.example/wms"); assert_eq!(first["add"]["params"]["type"], "wms"); assert_eq!(first["add"]["params"]["layer"], "a"); assert!(first["add"]["params"].get("source_url").is_some()); assert_eq!(v["children"].as_array().unwrap().len(), 2); let before = s.map().tiles.len(); let mut add = first["add"]["params"].clone(); add["text"] = json!(wms_caps); s.run("service.add", &add).unwrap(); assert_eq!(s.map().tiles.len(), before + 1); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().tiles.len(), before); // ArcGIS: a directory's folder is a node of its own address. new(&mut s, json!({"kind": "arcgis", "address": "https://x.example.org/arcgis/rest/services", "name": "Arc"})); let dir = r#"{"currentVersion": 11, "folders": ["Hydro"], "services": [{"name": "Topo", "type": "MapServer"}]}"#; let v = s.run("connection.browse", &json!({"id": "Arc", "answer": dir})).unwrap(); let folder = "service:arcgis:https://x.example.org/arcgis/rest/services/Hydro"; let child = v["children"].as_array().unwrap().iter().find(|c| c["node"] == folder).unwrap_or_else(|| panic!("{v}")); assert_eq!(child["what"], "folder"); assert_eq!(child["name"], "Hydro"); let v = s.run("connection.browse", &json!({"id": "Arc", "node": folder, "probe": true})).unwrap(); assert!(v["fetch"].as_str().is_some_and(|f| f.starts_with("https://x.example.org/arcgis/rest/services/Hydro")), "{v}"); // CSW: a record, the service it links to as a node, and more records than listed. new(&mut s, json!({"kind": "csw", "address": "https://cat.example.org/csw", "name": "Cat"})); let records = r#" Forests https://geo.example.org/wmshttp://192.168.1.2/wfs "#; let v = s.run("connection.browse", &json!({"id": "Cat", "answer": records})).unwrap(); assert_eq!(v["children"].as_array().unwrap().len(), 1, "{v}"); assert_eq!(v["children"][0]["what"], "record"); assert_eq!(v["children"][0]["children"][0]["node"], "service:wms:https://geo.example.org/wms"); assert_eq!((v["matched"].as_u64(), v["truncated"].as_bool()), (Some(3), Some(true))); assert!(v.get("next").is_none()); // STAC: collections, a collection's search, and an item's asset with the add of it. new(&mut s, json!({"kind": "stac", "address": "https://s.example/v1", "name": "Stac"})); let v = s.run("connection.browse", &json!({"id": "Stac", "probe": true})).unwrap(); assert_eq!(v["fetch"], "https://s.example/v1/collections"); let v = s.run("connection.browse", &json!({"id": "Stac", "answer": r#"{"collections":[{"id":"c1","title":"C"}]}"#})).unwrap(); assert_eq!(v["children"][0]["node"], "collection:c1"); assert_eq!(v["children"][0]["options"], true); let v = s.run("connection.browse", &json!({"id": "Stac", "node": "collection:c1", "probe": true})).unwrap(); let fetch = v["fetch"].as_str().unwrap(); assert!(fetch.contains("/search?") && fetch.contains("collections=c1") && fetch.contains("limit=20"), "{fetch}"); let self_link = "https://s.example/v1/collections/c1/items/i1"; let items = json!({"type": "FeatureCollection", "numberMatched": 1, "features": [{ "type": "Feature", "id": "i1", "collection": "c1", "bbox": [0, 0, 1, 1], "geometry": null, "properties": {"datetime": "2024-01-01T00:00:00Z"}, "assets": {"visual": {"href": "https://s.example/a.tif", "type": "image/tiff; application=geotiff"}}, "links": [{"rel": "self", "href": self_link}]}]}); let v = s.run("connection.browse", &json!({"id": "Stac", "node": "collection:c1", "answer": items.to_string()})).unwrap(); assert_eq!(v["children"][0]["what"], "item"); assert_eq!( v["children"][0]["children"][0]["add"], json!({"command": "stac.addAsset", "params": {"url": self_link, "asset": "visual", "name": "i1 visual", "connection": v["connection"]}}) ); assert!(v.get("truncated").is_none()); // A static STAC catalog: catalogs and collections, then a collection's items, each opened // for its assets. new(&mut s, json!({"kind": "stac", "address": "https://s.example/static/catalog.json", "name": "Static"})); let root = json!({"type": "Catalog", "id": "r", "links": [{"rel": "child", "href": "./c1/collection.json", "title": "C1"}]}); let v = s.run("connection.browse", &json!({"id": "Static", "answer": root.to_string()})).unwrap(); assert_eq!(v["children"][0]["node"], "catalog:https://s.example/static/c1/collection.json"); let collection = json!({"type": "Collection", "id": "c1", "links": [ {"rel": "item", "href": "./i1/i1.json", "title": "Scene 1"}, {"rel": "child", "href": "./sub/catalog.json", "title": "Sub"}]}); let node = "catalog:https://s.example/static/c1/collection.json"; let v = s.run("connection.browse", &json!({"id": "Static", "node": node, "answer": collection.to_string()})).unwrap(); let kinds: Vec<_> = v["children"].as_array().unwrap().iter().map(|c| (c["what"].as_str().unwrap(), c["node"].as_str().unwrap())).collect(); assert_eq!( kinds, [("catalog", "catalog:https://s.example/static/c1/sub/catalog.json"), ("item", "item:https://s.example/static/c1/i1/i1.json")] ); let item_node = "item:https://s.example/static/c1/i1/i1.json"; let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "probe": true})).unwrap(); assert_eq!(v["fetch"], "https://s.example/static/c1/i1/i1.json"); let item = json!({"type": "Feature", "id": "i1", "collection": "c1", "bbox": [0, 0, 1, 1], "geometry": null, "properties": {"datetime": "2024-01-01T00:00:00Z"}, "assets": {"visual": {"href": "https://s.example/static/a.tif", "type": "image/tiff; application=geotiff"}}, "links": [{"rel": "self", "href": "https://s.example/static/c1/i1/i1.json"}]}); let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "answer": item.to_string()})).unwrap(); assert_eq!( v["children"][0]["add"], json!({"command": "stac.addAsset", "params": {"url": "https://s.example/static/c1/i1/i1.json", "asset": "visual", "name": "i1 visual", "connection": v["connection"]}}) ); // An asset's key names its item, so the same file under two items is two rows. assert_eq!(v["children"][0]["key"], "https://s.example/static/c1/i1/i1.json#visual"); // A relative asset address is resolved against the item's own file. let relative = item.to_string().replace(r#""assets":{"#, r#""assets":{"data":{"href":"./data/b.tif","type":"image/tiff"},"#); let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "answer": relative})).unwrap(); let hrefs: Vec<&str> = v["children"].as_array().unwrap().iter().filter_map(|c| c["href"].as_str()).collect(); assert!(hrefs.contains(&"https://s.example/static/c1/i1/data/b.tif"), "{hrefs:?}"); // An item node belongs to a STAC connection, and is judged as its documents are. assert!(s.run("connection.browse", &json!({"id": "W", "node": item_node})).is_err()); assert!(s.run("connection.browse", &json!({"id": "Static", "node": "item:http://127.0.0.1:9/i.json"})).is_err()); // S3: buckets from the connection's options need no fetch; a bucket's level is read. new(&mut s, json!({"kind": "s3", "name": "BrowseB", "options": {"credentials": "none", "buckets": "bkt1"}})); let v = s.run("connection.browse", &json!({"id": "BrowseB", "probe": true})).unwrap(); assert!(v["fetch"].is_null(), "{v}"); let v = s.run("connection.browse", &json!({"id": "BrowseB"})).unwrap(); assert_eq!(v["children"][0]["node"], "s3:bkt1/"); let v = s.run("connection.browse", &json!({"id": "BrowseB", "node": "s3:bkt1/", "probe": true})).unwrap(); assert!(v["fetch"].as_str().is_some_and(|f| f.contains("bkt1")), "{v}"); let listing = "p/p.tif5q/0"; let v = s.run("connection.browse", &json!({"id": "BrowseB", "node": "s3:bkt1/", "answer": listing})).unwrap(); assert_eq!(v["children"][0]["node"], "s3:bkt1/p/"); assert_eq!(v["children"][0]["name"], "p/"); // Through a saved connection's login, a file's address names it: only that login reads it. let added = v["children"][1]["add"]["params"]["url"].as_str().unwrap(); let bound = rustgis_data::s3_address::parse(added).unwrap().unwrap(); let saved_id = &s.connections.find("BrowseB").unwrap().id; assert_eq!((bound.url, bound.endpoint, Some(bound.binding)), ("s3://bkt1/p.tif", "", s.s3_binding(saved_id))); assert_eq!(v["children"][1]["key"], "s3://bkt1/p.tif"); assert_eq!(v["children"].as_array().unwrap().len(), 2, "{v}"); s3::disconnect("BrowseB"); // Nodes that aren't the connection's, and ones that are hostile. let long = "x".repeat(5000); for (id, node) in [ ("W", "collection:x"), ("BrowseB", "s3:BAD_/"), ("W", long.as_str()), ("W", "service:ftp:x"), ("W", "service:wms:http://127.0.0.1:9/wms"), ("Stac", "catalog:http://127.0.0.1:9/c.json"), ("Stac", "s3:bkt1/"), ("W", "nope"), ("W", "a\u{7}b"), ] { let e = s.run("connection.browse", &json!({"id": id, "node": node})).unwrap_err(); assert!(!e.is_empty()); } assert!(s.run("connection.browse", &json!({"id": "missing"})).unwrap_err().contains("no connection")); #[cfg(not(target_arch = "wasm32"))] { new(&mut s, json!({"kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 dbname=x user=u", "name": "Pg"})); assert!(!s.run("connection.browse", &json!({"id": "Pg"})).unwrap_err().is_empty()); } // A catalogue's download link too long to be one (the parser keeps addresses to 2000 bytes) is not listed. let long_link = format!("https://files.example.org/{}.tif", "a".repeat(5000)); let records = format!( r#"Files https://files.example.org/a.tif{long_link}"# ); let v = s.run("connection.browse", &json!({"id": "Cat", "answer": records})).unwrap(); let links = v["children"][0]["children"].as_array().unwrap(); assert_eq!(links.len(), 1, "{v}"); assert_eq!(links[0]["add"]["params"]["url"], "https://files.example.org/a.tif"); assert!(v.get("truncated").is_none(), "{v}"); // An answer larger than a service document may be is refused before it is parsed. let huge = "x".repeat(65 << 20); assert!(s.run("connection.browse", &json!({"id": "W", "answer": huge})).unwrap_err().contains("too large")); assert!( s.run("connection.browse", &json!({"id": "W", "answer": "", "documents": [{"url": "https://a.example/x", "text": huge}]})) .unwrap_err() .contains("too large") ); // An OGC API's feature collections are imported together: listed, with options, not added alone. new(&mut s, json!({"kind": "ogcapi", "address": "https://o.example/collections", "name": "Ogc"})); let collections = r#"{"collections":[{"id":"lakes","title":"Lakes","links":[{"rel":"items","type":"application/geo+json","href":"https://o.example/collections/lakes/items"}]}]}"#; let v = s.run("connection.browse", &json!({"id": "Ogc", "answer": collections})).unwrap(); assert_eq!((v["children"][0]["what"].as_str(), v["children"][0]["options"].as_bool()), (Some("features"), Some(true)), "{v}"); assert!(v["children"][0].get("add").is_none(), "{v}"); let saved = s.connections.connections.len(); assert!(saved >= 5); s.run("connection.browse", &json!({"id": "W", "answer": wms_caps})).unwrap(); assert_eq!(s.connections.connections.len(), saved); assert!(!s.can_undo()); } fn rustgis_engine_sources_roundtrip(text: &str) -> crate::sources::Sources { crate::sources::Sources::from_json(text) } /// A DXF drawing through the commands: read natively (not through GDAL) by path and upload, its /// own layers listed and picked with "layer", and its coordinates converted with "crs". #[test] fn dxf_drawings_by_path_and_upload() { use base64::Engine; let pairs: &[(i32, &str)] = &[ (0, "SECTION"), (2, "ENTITIES"), (0, "LINE"), (8, "Roads"), (10, "500000"), (20, "4649776"), (11, "500100"), (21, "4649776"), (0, "POINT"), (8, "Wells"), (10, "500050"), (20, "4649800"), (0, "TEXT"), (8, "Wells"), (10, "500050"), (20, "4649800"), (1, "W1"), (0, "ENDSEC"), (0, "EOF"), ]; let text: String = pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect(); assert!(!rustgis_data::source::gdal::is_gdal_name("site.dxf")); let dir = std::env::temp_dir().join(format!("rustgis-dxf-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("site.dxf"); std::fs::write(&path, &text).unwrap(); let path = path.display().to_string(); let mut s = Session::new(Project::default()); let listed = s.run("info.fileLayers", &json!({"path": path})).unwrap(); assert_eq!(listed, json!({"count": 2, "layers": ["Roads", "Wells"]})); // Converted from UTM 33N: the line runs east from 15° E, 42° N. let r = s.run("layer.addData", &json!({"path": path, "crs": "EPSG:32633"})).unwrap(); let mut names: Vec = s.map().layers.iter().map(|l| l.name.clone()).collect(); names.sort(); assert_eq!(names, ["site lines", "site points"], "{r}"); let e = s.map().layer_by_name("site lines").unwrap().extent(); assert!((e.min_x - 15.0).abs() < 1e-6 && (e.min_y - 42.0).abs() < 0.01, "{e:?}"); // One DXF layer, by its name. let mut t = Session::new(Project::default()); t.run("layer.addData", &json!({"path": path, "layer": "wells", "crs": "EPSG:32633"})).unwrap(); let only: Vec = t.map().layers.iter().map(|l| l.name.clone()).collect(); assert_eq!(only, ["site points"]); assert_eq!(t.map().layers[0].dataset.features.len(), 2); assert!(t.run("layer.addData", &json!({"path": path, "layer": "nope"})).unwrap_err().contains("nothing drawn on layer 'nope'")); // Without "crs" the coordinates are kept, and the user is told. let mut u = Session::new(Project::default()); u.run("layer.addDataBytes", &json!({"fileName": "upload.dxf", "data": base64::engine::general_purpose::STANDARD.encode(&text)})) .unwrap(); assert_eq!(u.map().layers.len(), 2); assert!(u.notices.iter().any(|n| n.text.contains("doesn't say its coordinate system")), "{:?}", u.notices); assert!(u.run("layer.addDataBytes", &json!({"fileName": "bad.dxf", "data": "AAAA"})).is_err()); let _ = std::fs::remove_dir_all(dir); } /// Overture and Source Cooperative refuse what they can't read before asking anything of the /// network (their answers are read by the catalog modules' own tests). #[test] fn catalog_commands_refuse_bad_parameters() { let mut s = Session::new(Project::default()); for bad in [ json!({}), json!({"theme": "roads"}), json!({"theme": "buildings", "type": "segment"}), json!({"theme": "buildings", "release": "../x"}), json!({"theme": "buildings", "bbox": [1, 2, 3]}), json!({"theme": "buildings", "bbox": [10, 50, 0, 40]}), json!({"theme": "buildings", "bbox": "everywhere"}), ] { assert!(s.run("overture.add", &bad).is_err(), "{bad}"); } for bad in [ json!({}), json!({"account": "giswqs"}), json!({"account": "../etc", "product": "p"}), json!({"account": "a", "product": "p", "path": "../../x/"}), ] { assert!(s.run("sourcecoop.list", &bad).is_err(), "{bad}"); } assert!(s.run("sourcecoop.products", &json!({"account": "a/b"})).is_err()); assert!(s.map().layers.is_empty() && s.map().tiles.is_empty()); } /// Overture's files are added all or none: one that can't be read takes the ones added before it /// out again, so trying again doesn't add them twice. /// Runs the local catalog fixture through admission, as the real Overture handler does. fn catalog_files(s: &mut Session, urls: &[String]) -> crate::CmdResult { if s.registry.get("test.catalogFiles").is_none() { s.registry.commands.push( crate::CommandSpec::new("test.catalogFiles", "Local catalog files", "Test", |s, p| { let urls = p["urls"].as_array().unwrap().iter().map(|url| url.as_str().unwrap().to_string()).collect::>(); crate::cmd::catalogs::add_files(s, "building", &urls) }) .params(r#"{"urls": [text]}"#) .effects(crate::policy::Effects::NONE.with(crate::policy::Effect::Network)) .roles(&[]), ); } s.run("test.catalogFiles", &json!({"urls":urls})) } /// Overture's files are added all or none; the fixture retains their real command authority. #[test] fn overture_files_are_added_all_or_none() { use rustgis_data::{Dataset, Feature, Field, FieldType, Geometry, GeometryType, Value}; let mut ds = Dataset::new("b", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); for i in 0..50u32 { let c = rustgis_geom::Coord::new(7.0 + 0.01 * f64::from(i), 46.0); ds.features.push(Feature { id: u64::from(i) + 1, geometry: Some(Geometry::Point(c)), attrs: vec![Value::Int(i64::from(i))] }); } let (good, _) = serve_ranges(rustgis_data::source::fgb::write(&ds).unwrap()); let (bad, _) = serve_ranges(b"not a flatgeobuf at all".to_vec()); let mut s = Session::new(Project::default()); let before = s.map().layers.len(); let urls = vec![format!("{good}/part-0.fgb"), format!("{bad}/part-1.fgb")]; let e = catalog_files(&mut s, &urls).unwrap_err(); assert!(e.contains("none of the 2 files was added"), "{e}"); assert!(e.to_ascii_lowercase().contains("flatgeobuf"), "the actual bad file was reached: {e}"); assert_eq!(s.map().layers.len(), before, "the first file's layer was taken out again"); // The app's way (the files found and opened already) takes Overture's own files only: any // other address is refused before anything is read. let files = json!([format!("{good}/part-0.fgb"), format!("{bad}/part-1.fgb")]); let e = s.run("overture.add", &json!({"theme": "buildings", "files": files})).unwrap_err(); assert!(e.contains("not one of Overture's release files"), "{e}"); assert_eq!(s.map().layers.len(), before); for bad_files in [json!([]), json!("x"), json!(["https://x.org/a.png"]), json!(vec![format!("{good}/a.fgb"); 7])] { assert!(s.run("overture.add", &json!({"theme": "buildings", "files": bad_files})).is_err(), "{bad_files}"); } assert_eq!(s.map().layers.len(), before); // Both readable: both added, numbered. let urls = vec![format!("{good}/part-0.fgb"), format!("{good}/part-1.fgb")]; let r = catalog_files(&mut s, &urls).unwrap(); assert_eq!(r["files"], 2); let names: Vec<&str> = s.map().layers.iter().map(|l| l.name.as_str()).collect(); assert!(names.contains(&"Overture building (1/2)") && names.contains(&"Overture building (2/2)"), "{names:?}"); } /// A multi-file add that fails puts the map back exactly: the same layers in the same order, and /// the same active layer. #[test] fn a_failed_multi_file_add_leaves_the_map_as_it_was() { use rustgis_data::{Dataset, Feature, Field, FieldType, Geometry, GeometryType, Value}; let mut ds = Dataset::new("b", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); for i in 0..20u32 { let c = rustgis_geom::Coord::new(7.0 + 0.01 * f64::from(i), 46.0); ds.features.push(Feature { id: u64::from(i) + 1, geometry: Some(Geometry::Point(c)), attrs: vec![Value::Int(i64::from(i))] }); } let (good, _) = serve_ranges(rustgis_data::source::fgb::write(&ds).unwrap()); let (bad, _) = serve_ranges(b"not a flatgeobuf at all".to_vec()); let mut s = world(); // A layer other than the last added is active. let rivers = s.map().layer_by_name("Rivers").unwrap().id; s.active_layer = Some(rivers); let project = serde_json::to_string(&s.project).unwrap(); let urls = vec![format!("{good}/part-0.fgb"), format!("{good}/part-1.fgb"), format!("{bad}/part-2.fgb")]; assert!(catalog_files(&mut s, &urls).is_err()); assert_eq!(serde_json::to_string(&s.project).unwrap(), project, "the same layers, in the same order"); assert_eq!(s.active_layer, Some(rivers)); // A box across the antimeridian (west past east) is an area, not an error. let fiji = crate::cmd::catalogs::area(&s, &json!({"bbox": [178.0, -18.0, -178.0, -17.0]})).unwrap(); assert_eq!(fiji, [178.0, -18.0, -178.0, -17.0]); assert!(crate::cmd::catalogs::area(&s, &json!({"bbox": [200.0, 0.0, 10.0, 1.0]})).unwrap_err().contains("antimeridian")); } /// Geotagged photos: a folder of them becomes a point layer with each photo's time, camera and /// thumbnail; photos without a position are named, not added; an upload joins the Photos layer; /// a project keeps the thumbnails. #[test] fn geotagged_photos_become_a_photo_layer() { use base64::Engine; use rustgis_data::photo; let dir = std::env::temp_dir().join(format!("rustgis-photos-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("a.jpg"), photo::example(true, (43.4674, "N"), (11.8851, "E"))).unwrap(); std::fs::write(dir.join("b.JPEG"), photo::example(false, (43.4671, "N"), (11.8853, "E"))).unwrap(); // A JPEG without EXIF, and a file that isn't a photo. std::fs::write(dir.join("plain.jpg"), b"\xFF\xD8\xFF\xD9").unwrap(); std::fs::write(dir.join("notes.txt"), b"x").unwrap(); let mut s = Session::new(Project::default()); let r = s.run("layer.addPhotos", &json!({"folder": dir.display().to_string()})).unwrap(); assert_eq!((r["photos"].as_u64(), r["skipped"].as_u64()), (Some(2), Some(1)), "{r}"); assert!(s.notices.iter().any(|n| n.text.contains("plain.jpg")), "the photo without EXIF is named"); let l = s.map().layer_by_name("Photos").unwrap(); assert!(photo::is_photo_layer(&l.dataset)); let f = &l.dataset.features[0]; assert_eq!(f.attrs[0], rustgis_data::Value::Text("a".into())); assert_eq!(f.attrs[2], rustgis_data::Value::Text("2008-10-22 16:29:49".into())); assert!(matches!(&f.attrs[6], rustgis_data::Value::Text(t) if t.starts_with("data:image/jpeg;base64,"))); // An upload (the web app's way, one file at a time) joins the Photos layer. let one = base64::engine::general_purpose::STANDARD.encode(photo::example(true, (43.468, "N"), (11.881, "E"))); s.run("layer.addDataBytes", &json!({"fileName": "c.jpg", "data": one})).unwrap(); assert_eq!(s.map().layers.iter().filter(|l| l.name == "Photos").count(), 1); assert_eq!(s.map().layer_by_name("Photos").unwrap().dataset.features.len(), 3); // So does a photo added by its path. s.run("layer.addData", &json!({"path": dir.join("a.jpg").display().to_string()})).unwrap(); assert_eq!(s.map().layer_by_name("Photos").unwrap().dataset.features.len(), 4); // The project keeps them, thumbnails and all. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("Photos").unwrap(); assert_eq!(again.dataset.features.len(), 4); assert!(photo::is_photo_layer(&again.dataset)); // Nothing to add: an error, and nothing added. assert!(s.run("layer.addPhotos", &json!({})).is_err()); assert!(s.run("layer.addPhotos", &json!({"paths": [dir.join("plain.jpg").display().to_string()]})).is_err()); // Joining a layer one photo short of full: the first photo by name is added, the rest are // left out (and never read), and the map zooms to the photo added, not the far-off one. let near = dir.join("near-full"); std::fs::create_dir_all(&near).unwrap(); std::fs::write(near.join("b-far.jpg"), photo::example(true, (10.0, "S"), (50.0, "W"))).unwrap(); std::fs::write(near.join("a-near.jpg"), photo::example(true, (43.4674, "N"), (11.8851, "E"))).unwrap(); let id = s.map().layer_by_name("Photos").unwrap().id; let l = s.map_mut().layer_mut(id).unwrap(); let first = l.dataset.features[0].clone(); while l.dataset.features.len() < photo::MAX_PHOTOS - 1 { l.dataset.push(first.geometry.clone(), first.attrs.clone()); } let r = s.run("layer.addPhotos", &json!({"folder": near.display().to_string()})).unwrap(); assert_eq!((r["photos"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(1)), "{r}"); let l = s.map().layer_by_name("Photos").unwrap(); assert_eq!(l.dataset.features.len(), photo::MAX_PHOTOS); assert_eq!(l.dataset.features.last().unwrap().attrs[0], rustgis_data::Value::Text("a-near".into())); let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); assert!((c.x - 11.8851).abs() < 0.01 && (c.y - 43.4674).abs() < 0.01, "zoomed to {c:?}"); // Full: nothing more is added, and that's said. let r = s.run("layer.addPhotos", &json!({"folder": near.display().to_string()})); assert!(r.is_err_and(|e| e.contains("up to"))); assert_eq!(s.map().layer_by_name("Photos").unwrap().dataset.features.len(), photo::MAX_PHOTOS); let _ = std::fs::remove_dir_all(dir); } #[test] fn coverage_answers_that_are_not_geotiffs_are_errors() { use crate::cmd::service::coverage_check; assert!(coverage_check("c", b"II*\0rest").is_ok()); assert!(coverage_check("c", b"MM\0*rest").is_ok()); assert!(coverage_check("c", b"II+\0rest").is_ok()); for bad in [&b""[..], b"Service unavailable", b"", b"\xff\xd8\xff", b"MM: service down", b"II"] { let e = coverage_check("c", bad).err().unwrap_or_default(); assert!(e.starts_with("c: the service answered with an error"), "{e}"); } } /// The keys a command's source reads from its parameters, found by scanning `src/cmd/*.rs`: the /// command's function (or closure) and the functions it passes its parameters to (in its own /// file, or another's as `module::name(…)`), a few calls deep. /// Scans top-level Rust modules; helper subdirectories are not source files or command entries. fn keys_read_by_commands() -> Vec<(String, std::collections::BTreeSet)> { use std::collections::{BTreeMap, BTreeSet}; type Fn = (Vec, String); let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cmd"); // Each module's functions: their `&Value` arguments and bodies. let mut modules: BTreeMap)> = BTreeMap::new(); for entry in std::fs::read_dir(dir).unwrap() { let path = entry.unwrap().path(); if !path.is_file() || path.extension().is_none_or(|ext| ext != "rs") { continue; } let stem = path.file_stem().unwrap().to_string_lossy().to_string(); let src = std::fs::read_to_string(&path).unwrap(); let mut fns: BTreeMap = BTreeMap::new(); let mut rest = src.as_str(); while let Some(at) = rest.find("fn ") { let after = &rest[at + 3..]; let name: String = after.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); let Some(open) = after.find('{') else { break }; let body = balanced(&after[open..]); fns.entry(name).or_insert((value_args(&after[..open]), body.to_string())); rest = &after[open..]; } modules.insert(stem, (src, fns)); } let mut out = Vec::new(); for (stem, (src, fns)) in &modules { let mut rest = src.as_str(); while let Some(at) = rest.find("C::new(") { let call = balanced(&rest[at + 6..]); rest = &rest[at + 6..]; let parts: Vec<&str> = call.splitn(2, '"').collect(); let id: String = parts.get(1).unwrap_or(&"").chars().take_while(|c| *c != '"').collect(); // After the three strings: a function name or a closure. let mut tail = call; for _ in 0..6 { tail = &tail[tail.find('"').map_or(tail.len(), |i| i + 1)..]; } let tail = tail.trim_start_matches([',', ' ', '\n']); let mut keys = BTreeSet::new(); let mut todo: Vec<(String, Fn)> = Vec::new(); if let Some(closure) = tail.strip_prefix('|') { let args = closure[..closure.find('|').unwrap()].split(',').map(|a| a.trim().to_string()).skip(1).collect(); todo.push((stem.clone(), (args, tail.to_string()))); } else { let name: String = tail.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); todo.extend(fns.get(&name).cloned().map(|f| (stem.clone(), f))); } let mut seen = BTreeSet::new(); let mut depth = 0; while !todo.is_empty() && depth < 6 { for (module, (args, body)) in std::mem::take(&mut todo) { let args: Vec<&String> = args.iter().filter(|a| !a.starts_with('_')).collect(); for a in &args { // The helpers that read a key: `ps(p, "key")`… for h in ["ps", "f", "b", "uint", "s_req", "color", "get_color", "band", "point", "limit", "method"] { keys.extend(literal_keys(&body, &format!("{h}({a}, \""))); } keys.extend(literal_keys(&body, &format!("{a}.get(\""))); keys.extend(literal_keys(&body, &format!("{a}[\""))); if body.contains(&format!("layer_id({a})")) || body.contains(&format!("layer_id(&{a})")) { keys.insert("layer".to_string()); } if body.contains(&format!("table_index({a})")) { keys.insert("table".to_string()); } } // The functions it passes one of its parameter values to, at any position. for (m, (_, mfns)) in &modules { for (name, f) in mfns { let qualified = if m == "mod" { format!("super::{name}") } else { format!("{m}::{name}") }; let call = if *m == module { name.as_str() } else { qualified.as_str() }; let key = format!("{m}::{name}"); // A dispatcher by kind: its arms read keys for other commands. if key == "items::item_bytes" { continue; } if !seen.contains(&key) && args.iter().any(|a| calls(&body, call, a)) { seen.insert(key); todo.push((m.clone(), f.clone())); } } } } depth += 1; } out.push((id, keys)); } } out } /// `s` from its opening bracket to the matching close, skipping strings, raw strings and chars. fn balanced(s: &str) -> &str { let b = s.as_bytes(); let (mut depth, mut i) = (0i32, 0); while i < b.len() { match b[i] { b'r' if b.get(i + 1) == Some(&b'#') && b.get(i + 2) == Some(&b'"') => { i += s[i + 3..].find("\"#").map_or(b.len(), |n| n + 4); } b'"' => { i += 1; while i < b.len() && b[i] != b'"' { i += if b[i] == b'\\' { 2 } else { 1 }; } } b'\'' if b.get(i + 2) == Some(&b'\'') => i += 2, b'\'' if b.get(i + 1) == Some(&b'\\') && b.get(i + 3) == Some(&b'\'') => i += 3, b'{' | b'(' | b'[' => depth += 1, b'}' | b')' | b']' => { depth -= 1; if depth == 0 { return &s[..=i]; } } _ => {} } i += 1; } s } /// Whether `body` calls `name(…)` (not as part of a longer name) with `arg` itself as one of its /// arguments (`p`, `&p`, `p.clone()`). fn calls(body: &str, name: &str, arg: &str) -> bool { let word = |c: Option| c.is_some_and(|c| c.is_alphanumeric() || c == '_'); body.match_indices(&format!("{name}(")).any(|(i, m)| { let before = body[..i].chars().next_back(); if word(before) || before == Some('.') || (!name.contains("::") && before == Some(':')) { return false; } let args = balanced(&body[i + m.len() - 1..]); let inner = &args[1..args.len().saturating_sub(1)]; // The arguments, split at top-level commas. let (mut depth, mut start, mut found) = (0i32, 0, false); for (j, c) in inner.char_indices().chain([(inner.len(), ',')]) { match c { '(' | '[' | '{' => depth += 1, ')' | ']' | '}' => depth -= 1, ',' if depth == 0 => { let a = inner[start..j].trim(); found |= [arg.to_string(), format!("&{arg}"), format!("{arg}.clone()")].contains(&a.to_string()); start = j + 1; } _ => {} } } found }) } /// The names of the `&Value` arguments in a function signature. fn value_args(sig: &str) -> Vec { sig.split([',', '(']) .filter_map(|a| { a.split_once(':').filter(|(_, t)| t.trim().starts_with("&") && t.contains("Value")).map(|(n, _)| n.trim().to_string()) }) .collect() } /// The string literals that follow `prefix` (not part of a longer name) in `body`. fn literal_keys(body: &str, prefix: &str) -> Vec { body.match_indices(prefix) .filter(|(i, _)| !body[..*i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_')) .map(|(i, _)| body[i + prefix.len()..].chars().take_while(|c| *c != '"').collect::()) .filter(|k| !k.is_empty()) .collect() } /// Checks every discovered command's declared keys, even when helper directories accompany modules. #[test] fn params_name_every_key_a_command_reads() { // Read by a shared helper but of no use to the command. let unused = [ ("gp.spatialJoin", "input"), ("gp.zonalStats", "input"), ("gp.zonalStats", "target"), ("profile.alongSelected", "row"), ("sources.remove", "name"), ("sources.remove", "extra"), ]; let s = Session::new(Project::default()); let mut missing = Vec::new(); let found = keys_read_by_commands(); assert!(found.len() > 300, "the scan found {} commands", found.len()); for (id, keys) in found { let Some(spec) = s.registry.get(&id) else { continue }; let named = spec.param_keys().unwrap_or_default(); let absent: Vec<_> = keys .iter() .filter(|k| { !named.contains(&k.as_str()) && !spec.params.contains(&format!("\"{k}\"")) && !unused.contains(&(id.as_str(), k.as_str())) }) .collect(); if !absent.is_empty() { missing.push(format!("{id}: {absent:?} (params {})", spec.params)); } } assert!(missing.is_empty(), "{} commands read keys their params don't name:\n{}", missing.len(), missing.join("\n")); } #[test] fn needs_input_reads_descriptions() { use crate::needs_input; assert!(!needs_input("{}")); assert!(!needs_input(r#"{"layer"?, "name"?: text}"#)); assert!(needs_input(r#"{"layer": raster, "points"?: [[column, row, lon, lat], …]}"#)); assert!(needs_input(r#"{"path": folder | "url": "https://…", "name"?: text}"#)); assert!(needs_input(r#"{"url": "https://…/x.json?f=json"}"#)); assert!(!needs_input(r#"{"steps"?: [{"required": value}], "url"?: "https://…/?q=x"}"#)); assert!(needs_input("a GeoJSON text: {type: …}")); assert!(!needs_input("a GeoJSON text")); assert!(needs_input("a service URL: https://…/query?f=json")); let s = Session::new(Project::default()); assert!(s.registry.get("raster.georeference").is_some_and(crate::CommandSpec::needs_input)); assert!(s.registry.get("map.zoomIn").is_some_and(|c| !c.needs_input())); } /// A description splits into one entry per parameter, for help that lists them (#662): commas /// inside quotes, parentheses, brackets or braces stay in their entry. #[test] fn param_list_splits_a_description_into_entries() { use crate::{Param, param_list}; let desc = r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "steps"?: [{"tool": id}, …], "where": "a, b"}"#; let list = param_list(desc).unwrap(); let keys: Vec<(&str, bool)> = list.iter().map(|p| (p.key, p.optional)).collect(); assert_eq!(keys, [("layer", true), ("class", true), ("steps", true), ("where", false)]); assert_eq!(list[1].text, r#""class"?: name (none: the Default class, by whatever name it has)"#); assert_eq!(param_list("{}"), Some(Vec::::new())); // An escaped quote (a plugin's description may have one) doesn't end a value early. let escaped = param_list(r#"{"title"?: "a \"quoted, word\"", "n": number}"#).unwrap(); assert_eq!(escaped.iter().map(|p| p.key).collect::>(), ["title", "n"]); // Nor a key early: it's the whole key, as `param_keys` reads it. let key = param_list(r#"{"a\"b"?: text}"#).unwrap(); assert_eq!(key.iter().map(|p| (p.key, p.optional)).collect::>(), [(r#"a\"b"#, true)]); assert_eq!(crate::param_keys(r#"{"a\"b"?: text}"#), Some(vec![r#"a\"b"#])); assert_eq!(param_list("a GeoJSON text"), None); // Every command's description splits into entries, each led by a key `param_keys` finds: one // cut in the wrong place (an unclosed bracket or quote in its prose) would lead with another. let s = Session::new(Project::default()); for c in &s.registry.commands { let list = param_list(c.params).unwrap_or_else(|| panic!("{}: {}", c.id, c.params)); let keys = c.param_keys().unwrap_or_default(); for p in &list { assert!(keys.contains(&p.key), "{}: entry {:?} in {}", c.id, p.text, c.params); } } } /// Quoted text is read as JSON has it (#741): strings and their quotes aren't code, and a /// backslash escapes the char after it only in a string. #[test] fn quotes_read_text_as_json_does() { use crate::{Quotes, string_end}; let code = |s: &str| { let (mut q, mut out) = (Quotes::default(), String::new()); for c in s.chars() { if q.read(c) { out.push(c); } } out }; assert_eq!(code(r#"{"a": "b,c", "d": 1}"#), "{: , : 1}"); assert_eq!(code(r#"{"a\"b": 1}"#), "{: 1}", "an escaped quote"); assert_eq!(code(r#"{"a\\": 1}"#), "{: 1}", "an escaped backslash"); assert_eq!(code(r#"x \"a" y"#), r#"x \ y"#, "a backslash outside a string escapes nothing"); assert_eq!(code("ñ \"é\" ü"), "ñ ü", "chars, not bytes"); let mut q = Quotes::default(); for c in r#"{"a": "b"#.chars() { q.read(c); } assert!(q.quoted(), "ends in a string"); assert_eq!(string_end(r#"a\"b", 1"#), Some(4)); assert_eq!(string_end("é\""), Some(2), "a byte index"); assert_eq!(string_end(r#"a\""#), None, "nothing closes it"); assert_eq!(string_end(""), None); } #[test] fn param_keys_reads_descriptions() { use crate::param_keys; assert_eq!(param_keys("{}"), Some(vec![])); assert_eq!(param_keys(r#"{"layer"?, "name": string}"#), Some(vec!["layer", "name"])); // Values, nested objects and prose are not keys. assert_eq!( param_keys(r#"{"mode": "a"|"b", "steps"?: [{"tool": id}], "extent"?: [w, s, e, n] (clip, "view")}"#), Some(vec!["mode", "steps", "extent"]) ); // Alternatives. assert_eq!(param_keys(r#"{"path": folder | "url": "https://…", "name"?: text}"#), Some(vec!["path", "url", "name"])); assert_eq!(param_keys(r#"{"lon" | "x": number, "lat" | "y": number}"#), Some(vec!["lon", "x", "lat", "y"])); assert_eq!(param_keys(r##"{"color": "#RRGGBB"|"#RRGGBBAA"}"##), Some(vec!["color"])); // A quote after a backslash doesn't end a string (a Script line's value may have one); one // after an escaped backslash does. assert_eq!(param_keys(r#"{"lon": 2, "note": "a\"b", "lat": 3, ""#), Some(vec!["lon", "note", "lat"])); assert_eq!(param_keys(r#"{"a": "x\\", "b": 1}"#), Some(vec!["a", "b"])); assert_eq!(param_keys(r#"{"a": "x\"#), Some(vec!["a"]), "an unclosed string"); assert_eq!(param_keys("a GeoJSON text"), None); // Every command's description reads as keys, and names a key once. for c in &Session::new(Project::default()).registry.commands { let keys = c.param_keys().unwrap_or_else(|| panic!("{}: params {} are not an object", c.id, c.params)); let mut unique = keys.clone(); unique.sort_unstable(); unique.dedup(); assert_eq!(unique.len(), keys.len(), "{}: {}", c.id, c.params); } } /// A STAC trend's keys are cube.trend's own: given, none is said to be ignored (they sat inside /// the description's "collection" note, where the key reader doesn't look). #[test] fn a_stac_trends_keys_are_read() { let mut s = world(); let keys = s.registry.get("cube.trend").and_then(|c| c.param_keys()).unwrap(); for k in ["collection", "url", "asset", "bbox", "maxCloud", "cellSize", "resampling", "start", "end"] { assert!(keys.contains(&k), "{k}: {keys:?}"); } let mark = s.notice_mark(); // A cell size of 0: refused before any search, so nothing reaches the network, after the keys // are read. let p = json!({"collection": "c", "url": "http://127.0.0.1:9/v1", "asset": "NDVI", "bbox": [0, 0, 1, 1], "maxCloud": 20, "cellSize": 0, "resampling": "average", "start": "2020", "end": "2021"}); let e = s.run("cube.trend", &p).unwrap_err(); assert!(e.contains("cellSize"), "{e}"); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert!(said.iter().all(|t| !t.contains("ignores")), "{said:?}"); } #[test] fn unknown_params_are_said() { let mut s = world(); let mark = s.notice_mark(); s.run("layer.moveDown", &json!({"layer": "Cities", "steps": 13})).unwrap(); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(said, ["layer.moveDown ignores 'steps' (it reads layer)"]); // Known keys, and commands that take none, say nothing more. let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": "Cities", "name": "Towns"})).unwrap(); assert!(s.notices_since(mark).is_empty()); s.run("layout.close", &json!({"x": 1})).unwrap(); assert_eq!(s.notices_since(mark).first().map(|n| n.text.as_str()), Some("layout.close ignores 'x' (it reads no parameters)")); // Many or long keys are said briefly. let mut junk = serde_json::Map::new(); for i in 0..1000 { junk.insert(format!("{i:04}{}", "k".repeat(500)), Value::Null); } let mark = s.notice_mark(); s.run("layout.close", &Value::Object(junk)).unwrap(); let said = s.notices_since(mark).first().map(|n| n.text.clone()).unwrap_or_default(); assert!(said.len() < 500 && said.contains("995 more"), "{said}"); } /// A GeometryCollection of several members is refused by what is wrong with it. #[test] fn a_geometry_collection_of_several_is_refused_as_such() { use rustgis_data::{Coord, Geometry}; let mut s = world(); let point = json!({"type": "Point", "coordinates": [1, 2]}); let two = json!({"type": "GeometryCollection", "geometries": [point, point]}); let nested = json!({"type": "GeometryCollection", "geometries": [two]}); let empty = json!({"type": "GeometryCollection", "geometries": []}); for cmd in ["edit.addFeature", "edit.setGeometry"] { for g in [&two, &nested, &empty] { let e = s.run(cmd, &json!({"layer": "Cities", "row": 0, "geometry": g})).unwrap_err(); assert!(e.contains("a GeometryCollection must hold exactly one geometry"), "{cmd}: {e}"); } let e = s.run(cmd, &json!({"layer": "Cities", "row": 0, "geometry": {"type": "Blob"}})).unwrap_err(); assert!(e.contains("geometry must be a GeoJSON geometry"), "{cmd}: {e}"); } // One member, at any depth, is the geometry. let one = json!({"type": "GeometryCollection", "geometries": [{"type": "GeometryCollection", "geometries": [point]}]}); let before = s.map().layer_by_name("Cities").unwrap().dataset.features.len(); let added = s.run("edit.addFeature", &json!({"layer": "Cities", "geometry": one})).unwrap(); let row = added["row"].as_u64().unwrap() as usize; assert_eq!((row, s.map().layer_by_name("Cities").unwrap().dataset.features.len()), (before, before + 1)); s.run("edit.setGeometry", &json!({"layer": "Cities", "row": 0, "geometry": one})).unwrap(); let cities = &s.map().layer_by_name("Cities").unwrap().dataset; for r in [row, 0] { assert_eq!(cities.features[r].geometry, Some(Geometry::Point(Coord::new(1.0, 2.0))), "row {r}"); } } #[test] fn geojson_collection_import_reports_loss_even_without_surviving_geometry() { let collection = json!({"type":"GeometryCollection", "geometries":[ {"type":"Point", "coordinates":[1,2]}, {"type":"Polygon", "coordinates":[[[0,0],[4,0],[4,4],[0,0]]]} ]}); let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName":"collection.geojson", "text":collection.to_string()})).unwrap(); assert!(s.notices.iter().any(|n| n.text.contains("1 features had unreadable geometry"))); assert!(s.map().layers.is_empty(), "a partial geometry must never become a layer"); let mut clean = Session::new(Project::default()); clean.run("layer.addDataText", &json!({"fileName":"point.geojson", "text":r#"{"type":"Point","coordinates":[1,2]}"#})).unwrap(); assert!(!clean.notices.iter().any(|n| n.text.contains("unreadable geometry"))); } #[test] fn geojson_read_in_place_when_asked_or_large() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut ds = Dataset::new("pts", GeometryType::Point, vec![Field::new("i", FieldType::Integer)]); for i in 0..500 { ds.push(Some(Geometry::Point(Coord::new(-170.0 + i as f64 * 0.68, (i % 50) as f64))), vec![V::Int(i)]); } let p = std::env::temp_dir().join(format!("rustgis-engine-{}-pts.geojson", std::process::id())); rustgis_data::io::write_path(&ds, &p).unwrap(); // Small: loaded whole, as before. let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": p.display().to_string()})).unwrap(); assert!(!s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().is_streamed()); // Asked to stream: read in place, saved as its file, opened again in place. let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": p.display().to_string(), "stream": true})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; let v = view_of(&mut s, rustgis_geom::Extent::new(-180.0, -10.0, 180.0, 60.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert_eq!(s.map().layer(id).unwrap().dataset.features.len(), 500); let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert!(t.map().layers[0].is_streamed() && t.map().layers[0].source.as_ref().unwrap().is_open()); // Load All Features: the same features as a whole read. s.run("layer.loadAll", &json!({"layer": id})).unwrap(); let whole = rustgis_data::io::read_path(&p).unwrap().0; assert_eq!(s.map().layer(id).unwrap().dataset.features, whole.features); // Larger than IN_PLACE_ABOVE (trailing whitespace pads it): read in place without being asked, // and streamed past STREAM_ABOVE features. let n = rustgis_map::source::STREAM_ABOVE as usize + 1; let mut many = Dataset::new("many", GeometryType::Point, vec![Field::new("i", FieldType::Integer)]); for i in 0..n { many.push(Some(Geometry::Point(Coord::new(-179.0 + 358.0 * i as f64 / n as f64, 0.0))), vec![V::Int(i as i64)]); } let large = std::env::temp_dir().join(format!("rustgis-engine-{}-large.geojson", std::process::id())); let mut text = rustgis_data::io::to_geojson(&many).to_string().into_bytes(); let json_len = text.len(); let pad = (rustgis_data::source::geojson::IN_PLACE_ABOVE as usize + 1).saturating_sub(text.len()); text.resize(text.len() + pad, b' '); std::fs::write(&large, &text).unwrap(); let mut u = Session::new(Project::default()); let r = u.run("layer.addData", &json!({"path": large.display().to_string()})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); assert_eq!(r["features"], n as u64, "{r}"); // A large file that isn't JSON says why, rather than falling back to a whole read. text.truncate(json_len / 2); text.resize(rustgis_data::source::geojson::IN_PLACE_ABOVE as usize + 1, b' '); std::fs::write(&large, &text).unwrap(); let e = u.run("layer.addData", &json!({"path": large.display().to_string()})).unwrap_err(); assert!(e.contains("GeoJSON") && !e.contains("too large"), "{e}"); let _ = std::fs::remove_file(&large); // A document that isn't a FeatureCollection is read whole even when asked to stream. let one = std::env::temp_dir().join(format!("rustgis-engine-{}-one.geojson", std::process::id())); std::fs::write(&one, r#"{"type":"Point","coordinates":[1,2]}"#).unwrap(); let r = s.run("layer.addData", &json!({"path": one.display().to_string(), "stream": true})).unwrap(); assert!(!s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().is_streamed()); let _ = std::fs::remove_file(p); let _ = std::fs::remove_file(one); } /// Emptying the Shapefile index cache keeps the index of a layer that's open, says so, and the /// layer still reads (#1259). #[test] fn emptying_the_index_cache_keeps_indexes_in_use() { let p = line_of_points_shp("index-cache", 20); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": p.display().to_string(), "stream": true})).unwrap(); let mark = s.notice_mark(); let out = s.run("cache.emptyIndexes", &Value::Null).unwrap(); assert!(out["inUse"].as_u64().unwrap() >= 1, "{out}"); assert!(out["removed"].is_u64() && out["freedBytes"].is_u64(), "{out}"); let notices = s.notices_since(mark); assert!(notices.iter().any(|n| n.text.contains("in use") && !n.error), "{notices:?}"); let layer = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(layer.load_all().unwrap().features.len(), 20); drop(s); remove_shp(&p); } /// Emptying the index cache tells the user only what it couldn't do: nothing when all went (#1259). #[cfg(not(target_arch = "wasm32"))] #[test] fn emptying_the_index_cache_notices_only_what_stayed() { use crate::cmd::workspace::empty_notices; let mut e = rustgis_data::source::shp::Emptied::default(); assert!(empty_notices(&e).is_empty()); e.removed = 3; e.removed_bytes = 4096; assert!(empty_notices(&e).is_empty(), "what it freed is in the result, not a notice"); e.in_use = 1; e.in_use_bytes = 2048; e.kept_bytes = 3 * 1024 * 1024; e.failed.push("/somewhere: refused".to_string()); let notices = empty_notices(&e); assert!(notices.iter().any(|(t, error)| *error && t.contains("/somewhere: refused")), "{notices:?}"); assert!(notices.iter().any(|(t, error)| !*error && t.contains("1 index in use") && t.contains("2.0 KB")), "{notices:?}"); assert!(notices.iter().any(|(t, error)| !*error && t.contains("not removed") && t.contains("3.0 MB")), "{notices:?}"); } /// A Shapefile of `n` points along the equator, numbered. fn line_of_points_shp(name: &str, n: usize) -> std::path::PathBuf { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut ds = Dataset::new(name, GeometryType::Point, vec![Field::new("i", FieldType::Integer)]); for i in 0..n { ds.push(Some(Geometry::Point(Coord::new(-179.0 + 358.0 * i as f64 / n.max(2) as f64, 0.0))), vec![V::Int(i as i64)]); } let p = std::env::temp_dir().join(format!("rustgis-engine-{}-{name}.shp", std::process::id())); rustgis_data::io::write_path(&ds, &p).unwrap(); p } /// Removes a Shapefile written by [`line_of_points_shp`] with its sidecar files. fn remove_shp(p: &std::path::Path) { for ext in ["shp", "shx", "dbf", "prj", "cpg"] { let _ = std::fs::remove_file(p.with_extension(ext)); } } #[test] fn shapefiles_too_large_to_load_whole_still_open() { // Few records, but a .shp or .dbf past what the whole-file reader takes (#1055): opened and // kept in place, so it never has to fit in memory whole, rather than refused as too large. // Grown sparse where the file system allows. for grown in ["dbf", "shp"] { let p = line_of_points_shp(&format!("wide-{grown}"), 20); std::fs::OpenOptions::new().write(true).open(p.with_extension(grown)).unwrap().set_len(rustgis_data::io::MAX_BYTES + 1).unwrap(); let path = p.display().to_string(); // With and without a coordinate system given for it. for params in [json!({"path": path}), json!({"path": path, "crs": "EPSG:4326"})] { let mut s = Session::new(Project::default()); let r = s.run("layer.addData", ¶ms).unwrap(); assert_eq!(r["features"], 20, "{grown}: {r}"); assert_eq!(r["streamed"], true, "{grown}: {r}"); assert!(s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().is_streamed()); } // "stream": false still asks for the whole-file reader, which refuses it. let mut s = Session::new(Project::default()); let e = s.run("layer.addData", &json!({"path": path, "stream": false})).unwrap_err(); assert!(e.contains("too large"), "{grown}: {e}"); remove_shp(&p); } } #[test] fn large_shapefiles_are_read_in_place() { // Small: loaded whole, so it can be edited. let small = line_of_points_shp("small-shp", 50); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": small.display().to_string()})).unwrap(); let id = r["layer"].as_u64().unwrap() as u32; assert!(!s.map().layer(id).unwrap().is_streamed()); assert_eq!(s.map().layer(id).unwrap().dataset.features.len(), 50); // Past the streaming threshold: read in place, a view at a time. let n = rustgis_map::source::STREAM_ABOVE as usize + 1; let big = line_of_points_shp("big-shp", n); let mut s = Session::new(Project::default()); let r = s.run("layer.addData", &json!({"path": big.display().to_string()})).unwrap(); assert_eq!(r["streamed"], true, "{r}"); assert_eq!(r["features"], n as u64, "{r}"); let id = r["layer"].as_u64().unwrap() as u32; let v = view_of(&mut s, rustgis_geom::Extent::new(-10.0, -5.0, 10.0, 5.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let l = s.map().layer(id).unwrap(); assert!(l.is_streamed()); assert!(!l.dataset.features.is_empty() && l.dataset.features.len() < n); // Saved as its file, opened again in place. let text = serde_json::to_string(&s.project).unwrap(); assert!(text.len() < 20_000, "{}", text.len()); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); assert!(t.map().layers[0].is_streamed() && t.map().layers[0].source.as_ref().unwrap().is_open()); // Load All Features: every point, editable. let r = s.run("layer.loadAll", &json!({"layer": id})).unwrap(); assert_eq!(r["features"], n as u64); assert!(!s.map().layer(id).unwrap().is_streamed()); // "stream": false loads even a large one whole. let mut u = Session::new(Project::default()); let r = u.run("layer.addData", &json!({"path": big.display().to_string(), "stream": false})).unwrap(); assert!(!u.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().is_streamed()); for p in [small, big] { remove_shp(&p); } } /// A PostGIS layer in a project names its connection without the password, and opening the /// project never sends the user's own credentials: a server that wants one is reported, with how /// to connect again. #[cfg(not(target_arch = "wasm32"))] #[test] fn postgis_layers_reopen_without_secrets() { // Nothing listens on port 1: the connection is refused at once. let mut s = Session::new(Project::default()); let mut src: rustgis_map::source::SourceLayer = serde_json::from_value(json!({ "postgis": {"database": "postgres:host=127.0.0.1 port=1 dbname=gis user=me", "table": "roads", "family": "Lines"} })) .unwrap(); let e = src.open().unwrap_err(); assert!(e.contains("connect again with Home › Add › Add Data › Database"), "{e}"); assert!(!src.is_open()); // Saved again, it keeps its table and never a password. let saved = serde_json::to_string(&src).unwrap(); assert!(saved.contains("\"table\":\"roads\"") && !saved.contains("password"), "{saved}"); // A connection that isn't there fails as an error, never a panic, and adds nothing. let before = s.map().layers.len(); let e = s.run("layer.addDatabase", &json!({"database": "postgres:host=127.0.0.1 port=1 dbname=gis", "password": "pw"})).unwrap_err(); assert!(e.starts_with("gis: ") && !e.contains("pw"), "{e}"); let e = s.run("info.databaseTables", &json!({"database": "postgresql://u:pw@127.0.0.1:1/gis"})).unwrap_err(); assert!(!e.contains(":pw"), "{e}"); assert_eq!(s.map().layers.len(), before); } /// A live PostGIS database through the commands: `RUSTGIS_TEST_POSTGIS="postgres:host=… dbname=… /// user=… password=…"` names one with PostGIS's `us_cities`-style tables (any will do); every /// table is added, drawn, saved and opened again. #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore] fn postgis_live_end_to_end() { let Ok(dsn) = std::env::var("RUSTGIS_TEST_POSTGIS") else { return }; let mut s = Session::new(Project::default()); let listed = s.run("info.databaseTables", &json!({"database": dsn})).unwrap(); let names: Vec = listed["layers"].as_array().unwrap().iter().filter_map(|v| v.as_str()).map(str::to_string).collect(); assert!(!names.is_empty()); let r = s.run("layer.addDatabase", &json!({"database": dsn, "layer": names[0]})).unwrap(); assert!(r["features"].as_u64().unwrap() > 0, "{r}"); // Streamed when asked: saved without its features or the password, read again when opened. let r = s.run("layer.addDatabase", &json!({"database": dsn, "layer": names[0], "stream": true, "name": "live"})).unwrap(); assert_eq!(r["streamed"], true); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().source.as_ref().unwrap().postgis.is_some()); let dir = std::env::temp_dir().join(format!("rustgis-engine-postgis-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("map.png"); s.run("share.exportMap", &json!({"path": png.display().to_string()})).unwrap(); assert!(std::fs::metadata(&png).unwrap().len() > 1000); let text = serde_json::to_string(&s.project).unwrap(); assert!(!text.contains("password"), "the project keeps no password"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = t.map().layer_by_name("live").unwrap(); assert!(l.source.as_ref().unwrap().is_open(), "{:?}", l.source.as_ref().unwrap().error); let _ = std::fs::remove_dir_all(&dir); } /// A MySQL or MariaDB layer in a project names its connection without the password, and opening /// the project never sends one it wasn't given this session (`MYSQL_PWD` included). #[cfg(not(target_arch = "wasm32"))] #[test] fn mysql_layers_reopen_without_secrets() { let mut s = Session::new(Project::default()); let mut src: rustgis_map::source::SourceLayer = serde_json::from_value(json!({ "mysql": {"database": "mysql://me@127.0.0.1:1/gis?ssl-mode=DISABLED", "table": "roads", "family": "Lines"} })) .unwrap(); let e = src.open().unwrap_err(); assert!(e.contains("connect again with Home › Add › Add Data › Database"), "{e}"); assert!(!src.is_open()); let saved = serde_json::to_string(&src).unwrap(); assert!(saved.contains("\"mysql\":{") && saved.contains("\"table\":\"roads\"") && !saved.contains("password"), "{saved}"); // A connection that isn't there fails as an error, never a panic, and adds nothing; the // password, in the address or given beside it, is in no message. let before = s.map().layers.len(); let e = s.run("layer.addDatabase", &json!({"database": "mysql://me:s3cret@127.0.0.1:1/gis", "password": "pw"})).unwrap_err(); assert!(e.starts_with("gis: ") && !e.contains("s3cret") && !e.contains("pw"), "{e}"); let e = s.run("info.databaseTables", &json!({"database": "mariadb://u:s3cret@127.0.0.1:1"})).unwrap_err(); assert!(e.starts_with("mysql: ") && !e.contains("s3cret"), "{e}"); // An address that can't be read is said so. let e = s.run("layer.addDatabase", &json!({"database": "mysql://h:port/gis"})).unwrap_err(); assert!(e.contains("port 'port'"), "{e}"); // SQL queries don't run on one. let e = s.run("layer.addSql", &json!({"sql": "SELECT 1", "database": "mysql://h/gis"})).unwrap_err(); assert!(e.contains("MySQL"), "{e}"); assert_eq!(s.map().layers.len(), before); } /// A live MySQL or MariaDB database through the commands: `RUSTGIS_TEST_MYSQL="mysql://user: /// password@host:port/database"` names one with tables of geometry (any will do); every table is /// added, drawn, saved and opened again. #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore] fn mysql_live_end_to_end() { let Ok(address) = std::env::var("RUSTGIS_TEST_MYSQL") else { return }; let mut s = Session::new(Project::default()); let listed = s.run("info.databaseTables", &json!({"database": address})).unwrap(); let names: Vec = listed["layers"].as_array().unwrap().iter().filter_map(|v| v.as_str()).map(str::to_string).collect(); assert!(!names.is_empty()); // Every table: a column of mixed types becomes a layer per type. let r = s.run("layer.addDatabase", &json!({"database": address})).unwrap(); assert!(r["layers"].as_array().unwrap().len() >= names.len(), "{r}"); assert!(r["features"].as_u64().unwrap() > 0, "{r}"); // Streamed when asked: saved without its features or the password, read again when opened. let r = s.run("layer.addDatabase", &json!({"database": address, "layer": names[0], "stream": true, "name": "live"})).unwrap(); assert_eq!(r["streamed"], true); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().source.as_ref().unwrap().mysql.is_some()); let dir = std::env::temp_dir().join(format!("rustgis-engine-mysql-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("map.png"); s.run("share.exportMap", &json!({"path": png.display().to_string()})).unwrap(); assert!(std::fs::metadata(&png).unwrap().len() > 1000); let text = serde_json::to_string(&s.project).unwrap(); let password = address.split_once("://").and_then(|(_, r)| r.split_once('@')).and_then(|(u, _)| u.split_once(':')).map(|(_, p)| p); assert!(password.is_none_or(|p| !text.contains(&format!(":{p}@"))), "the project keeps no password"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = t.map().layer_by_name("live").unwrap(); assert!(l.source.as_ref().unwrap().is_open(), "{:?}", l.source.as_ref().unwrap().error); let _ = std::fs::remove_dir_all(&dir); } /// SQL Server project reopening and failed commands never expose credentials or add layers. #[cfg(not(target_arch = "wasm32"))] #[test] fn sqlserver_layers_reopen_without_secrets() { let mut s = Session::new(Project::default()); let mut src: rustgis_map::source::SourceLayer = serde_json::from_value(json!({ "sqlserver": {"database": "mssql://me@127.0.0.1:1/gis?encrypt=false", "table": "roads", "family": "Lines"} })) .unwrap(); let e = src.open().unwrap_err(); assert!(e.contains("connect again with Home › Add › Add Data › Database"), "{e}"); assert!(!src.is_open()); let saved = serde_json::to_string(&src).unwrap(); assert!(saved.contains("\"sqlserver\":{") && saved.contains("\"table\":\"roads\"") && !saved.contains("password"), "{saved}"); let before = s.map().layers.len(); let e = s.run("layer.addDatabase", &json!({"database": "mssql://me:s3cret@127.0.0.1:1/gis", "password": "pw"})).unwrap_err(); assert!(e.starts_with("gis: ") && !e.contains("s3cret") && !e.contains("pw"), "{e}"); let e = s.run("layer.addDatabase", &json!({"database": "mssql://h:port/gis"})).unwrap_err(); assert!(e.contains("port 'port'"), "{e}"); let e = s.run("layer.addSql", &json!({"sql": "SELECT 1", "database": "mssql://h/gis"})).unwrap_err(); assert!(e.contains("SQL Server"), "{e}"); #[cfg(not(windows))] { let e = s.run("info.databaseTables", &json!({"database": "mssql://127.0.0.1:1/gis"})).unwrap_err(); assert!(e.contains("give a SQL Server login"), "{e}"); } assert_eq!(s.map().layers.len(), before); } /// `RUSTGIS_TEST_MSSQL` names a live database: add every table, stream one, draw, save and reopen. #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore] fn sqlserver_live_end_to_end() { let Ok(address) = std::env::var("RUSTGIS_TEST_MSSQL") else { return }; let mut s = Session::new(Project::default()); let listed = s.run("info.databaseTables", &json!({"database": address})).unwrap(); let names: Vec = listed["layers"].as_array().unwrap().iter().filter_map(|v| v.as_str()).map(str::to_string).collect(); assert!(!names.is_empty()); let r = s.run("layer.addDatabase", &json!({"database": address})).unwrap(); assert!(r["layers"].as_array().unwrap().len() >= names.len(), "{r}"); assert!(r["features"].as_u64().unwrap() > 0, "{r}"); let r = s.run("layer.addDatabase", &json!({"database": address, "layer": names[0], "stream": true, "name": "live"})).unwrap(); assert_eq!(r["streamed"], true); let id = r["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().source.as_ref().unwrap().sqlserver.is_some()); let dir = std::env::temp_dir().join(format!("rustgis-engine-sqlserver-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string()})).unwrap(); let text = serde_json::to_string(&s.project).unwrap(); let password = rustgis_data::source::msconn::split_password(&address).and_then(|(_, p)| p); assert!(password.is_none_or(|p| !text.contains(&p)), "the project keeps no password"); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let l = t.map().layer_by_name("live").unwrap(); assert!(l.source.as_ref().unwrap().is_open(), "{:?}", l.source.as_ref().unwrap().error); let _ = std::fs::remove_dir_all(&dir); } /// Deleting a map or layout other than the one in view (a tab's close button) keeps that view; /// deleting the one in view falls back to the first map; the last map stays. #[test] fn removing_another_map_or_layout_keeps_the_one_in_view() { let mut s = world(); for n in ["B", "C"] { s.run("map.new", &json!({"name": n})).unwrap(); } let active = |s: &Session| s.map().name.clone(); assert_eq!(active(&s), "C"); s.run("map.remove", &json!({"name": "B"})).unwrap(); assert_eq!(active(&s), "C", "a map before the active one"); s.run("map.new", &json!({"name": "D"})).unwrap(); s.run("map.activate", &json!({"name": "C"})).unwrap(); s.run("map.remove", &json!({"name": "D"})).unwrap(); assert_eq!(active(&s), "C", "a map after the active one"); let first = s.project.maps[0].name.clone(); s.run("map.remove", &json!({"name": "C"})).unwrap(); assert_eq!((active(&s), s.project.maps.len()), (first.clone(), 1), "the active map itself"); assert!(s.run("map.remove", &json!({"name": first})).is_err(), "the last map"); assert_eq!(s.project.maps.len(), 1); for n in ["L1", "L2", "L3"] { s.run("layout.new", &json!({"name": n})).unwrap(); } let open = |s: &Session| s.active_layout.and_then(|i| s.project.layouts.get(i)).map(|l| l.name.clone()); s.run("layout.activate", &json!({"name": "L2"})).unwrap(); s.run("layout.remove", &json!({"name": "L1"})).unwrap(); assert_eq!(open(&s).as_deref(), Some("L2"), "a layout before the open one"); s.run("layout.remove", &json!({"name": "L3"})).unwrap(); assert_eq!(open(&s).as_deref(), Some("L2"), "a layout after the open one"); s.run("layout.remove", &json!({"name": "L2"})).unwrap(); assert_eq!((open(&s), s.project.layouts.len()), (None, 0), "the open layout itself"); s.run("layout.new", &json!({"name": "L4"})).unwrap(); s.run("layout.close", &Value::Null).unwrap(); s.run("layout.remove", &json!({"name": "L4"})).unwrap(); assert_eq!(open(&s), None, "with the map in view, it stays in view"); } /// A map's name finds one map (tabs, Browser, map frames): renaming to another map's name adds a /// number, and a map keeps its own name in another case. #[test] fn map_rename_keeps_map_names_unique() { let mut s = world(); let first = s.map().name.clone(); s.run("map.new", &json!({"name": "B"})).unwrap(); let r = s.run("map.rename", &json!({"name": first.to_uppercase()})).unwrap(); assert_eq!(r["name"], format!("{} 2", first.to_uppercase())); s.run("map.new", &json!({"name": "C"})).unwrap(); assert_eq!(s.run("map.rename", &json!({"name": format!("{} 2", first)})).unwrap()["name"], format!("{first} 2 2")); let own = format!("{} 2 2", first.to_lowercase()); assert_eq!(s.run("map.rename", &json!({"name": own})).unwrap()["name"], own.as_str(), "its own name, in another case"); let names: Vec = s.project.maps.iter().map(|m| m.name.to_lowercase()).collect(); let mut unique = names.clone(); unique.sort(); unique.dedup(); assert_eq!(unique.len(), names.len(), "{names:?}"); for p in [json!({}), json!({"name": " "}), json!({"name": 3})] { assert!(s.run("map.rename", &p).is_err(), "{p}"); } // A long name keeps to 200 characters, number included. let long = "é".repeat(300); s.run("map.rename", &json!({"name": long})).unwrap(); s.run("map.new", &Value::Null).unwrap(); let r = s.run("map.rename", &json!({"name": long})).unwrap(); let name = r["name"].as_str().unwrap_or_default(); assert_eq!((name.chars().count(), name.ends_with(" 2")), (200, true), "{name}"); } #[test] fn bookmarks_export_and_import_across_projections() { let mut s = Session::new(sample::world_project()); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 5_000_000})).unwrap(); s.run("bookmark.add", &json!({"name": "Here"})).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-bookmarks-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("marks.rgisbookmarks").display().to_string(); s.run("bookmark.save", &json!({"path": file})).unwrap(); let before = s.map().bookmarks.len(); s.run("bookmark.remove", &json!({"name": "Here"})).unwrap(); assert_eq!(s.map().bookmarks.len(), before - 1); // Imported into a map in another projection, the bookmark still shows the same place. s.run("map.projection", &json!({"type": "europeEqualArea"})).unwrap(); let r = s.run("bookmark.import", &json!({"path": file})).unwrap(); // Places a Europe-only projection can't show are left out; same-named bookmarks are replaced. let added = r["added"].as_array().unwrap(); assert!(added.iter().any(|n| n == "Here") && !added.iter().any(|n| n == "Americas"), "{added:?}"); assert_eq!(s.map().bookmarks.len(), before); // Re-importing replaces bookmarks where they are, and names each once. let order: Vec = s.map().bookmarks.iter().map(|b| b.name.clone()).collect(); let twice = json!({"bookmarks": [{"name": "Europe", "lon": 10, "lat": 50, "metresPerPixel": 1000}, {"name": "europe", "lon": 11, "lat": 50, "metresPerPixel": 1000}]}); let r = s.run("bookmark.import", &json!({"text": twice.to_string()})).unwrap(); assert_eq!(r["added"], json!(["Europe"])); assert_eq!(s.map().bookmarks.iter().map(|b| b.name.clone()).collect::>(), order); s.run("bookmark.go", &json!({"name": "Here"})).unwrap(); let c = s.map().center_lonlat(); assert!((c.x - 10.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6, "{c:?}"); assert!((s.scale() / 5_000_000.0 - 1.0).abs() < 0.02, "{}", s.scale()); // Junk is refused, and junk entries are skipped. assert!(s.run("bookmark.import", &json!({"text": "{}"})).is_err()); let junk = json!({"bookmarks": [{"name": "Bad", "lon": 500, "lat": 0, "metresPerPixel": 1}, {"name": "", "lon": 0, "lat": 0, "metresPerPixel": 1}]}); assert!(s.run("bookmark.import", &json!({"text": junk.to_string()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } /// The map turns to a bearing (#395): set, turned further, reset, kept by bookmarks (and their /// files), saved with the project, and reported by project info. #[test] fn the_map_turns_to_a_bearing() { let mut s = Session::new(sample::world_project()); assert_eq!(s.map().camera.bearing, 0.0); assert_eq!(s.run("map.setBearing", &json!({"bearing": 90})).unwrap(), json!({"bearing": 90.0})); assert_eq!(s.run("map.setBearing", &json!({"by": -100})).unwrap(), json!({"bearing": -10.0})); assert_eq!(s.run("map.setBearing", &json!({"bearing": 350})).unwrap(), json!({"bearing": -10.0})); assert!(s.run("map.setBearing", &json!({})).is_err()); assert!(s.run("map.setBearing", &json!({"bearing": "north"})).is_err()); assert!(s.run("map.setBearing", &json!({"bearing": "north", "by": 10})).is_err()); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 50.0, "scale": 5_000_000, "bearing": 45})).unwrap(); assert_eq!(s.map().camera.bearing, 45.0); assert!(s.run("map.goToXY", &json!({"lon": 0.0, "lat": 0.0, "bearing": "east"})).is_err()); assert!((s.map().center_lonlat().x - 10.0).abs() < 1e-6, "a refused bearing doesn't move the map"); // Previous Extent turns the map back. s.run("map.setBearing", &json!({"bearing": 0})).unwrap(); s.run("map.prevExtent", &json!({})).unwrap(); assert_eq!(s.map().camera.bearing, 45.0); assert_eq!(s.run("info.project", &json!({})).unwrap()["bearing"], json!(45.0)); // A bookmark keeps the bearing, in the project and in a bookmarks file. s.run("bookmark.add", &json!({"name": "Turned"})).unwrap(); s.run("map.resetNorth", &json!({})).unwrap(); assert_eq!(s.map().camera.bearing, 0.0); s.run("bookmark.go", &json!({"name": "Turned"})).unwrap(); assert_eq!(s.map().camera.bearing, 45.0); let file = crate::cmd::navigate::bookmarks_file(s.map()).unwrap(); s.run("map.resetNorth", &json!({})).unwrap(); s.run("bookmark.remove", &json!({"name": "Turned"})).unwrap(); s.run("bookmark.import", &json!({"text": String::from_utf8(file).unwrap()})).unwrap(); s.run("bookmark.go", &json!({"name": "Turned"})).unwrap(); assert!((s.map().camera.bearing - 45.0).abs() < 1e-9); // Saved with the project. let saved: crate::Project = serde_json::from_str(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert!((saved.maps[saved.active_map].camera.bearing - 45.0).abs() < 1e-9); // The visible extent holds the turned view's corners: wider than the same view facing north. let turned = crate::cmd::workspace::view_extent(&s); s.run("map.resetNorth", &json!({})).unwrap(); let north = crate::cmd::workspace::view_extent(&s); assert!(turned.width() > north.width() * 1.1, "{turned:?} vs {north:?}"); } /// The map tilts (#395 phase 2): set, tilted further, kept within 0 to 85, reset with the /// bearing, kept by bookmarks and their files, in Previous Extent and in project info; a tilted /// map exports. #[test] fn the_map_tilts() { let mut s = Session::new(sample::world_project()); assert_eq!(s.run("map.setPitch", &json!({"pitch": 50})).unwrap(), json!({"pitch": 50.0})); assert_eq!(s.run("map.setPitch", &json!({"by": 50})).unwrap(), json!({"pitch": 85.0})); assert_eq!(s.run("map.setPitch", &json!({"by": -100})).unwrap(), json!({"pitch": 0.0})); assert!(s.run("map.setPitch", &json!({})).is_err()); assert!(s.run("map.setPitch", &json!({"pitch": "steep", "by": 5})).is_err()); assert_eq!(s.run("map.setPitch", &json!({"by": "up"})).unwrap_err(), "by must be a number of degrees"); // Huge pitches are kept to the limit; no number (NaN, infinity) is refused. assert_eq!(s.run("map.setPitch", &json!({"pitch": 1e300})).unwrap(), json!({"pitch": 85.0})); assert_eq!(s.run("map.setPitch", &json!({"pitch": -1e300})).unwrap(), json!({"pitch": 0.0})); for junk in [json!("NaN"), json!("inf"), json!("-infinity"), json!([1]), json!({})] { assert!(s.run("map.setPitch", &json!({"pitch": junk})).is_err(), "{junk}"); // A refused Go To XY leaves the view where it was. let before = s.map().camera; assert!(s.run("map.goToXY", &json!({"lon": 30, "lat": 30, "pitch": junk})).is_err(), "{junk}"); assert_eq!(s.map().camera, before); } assert!(s.run("map.goToXY", &json!({"lon": 0, "lat": 0, "pitch": "steep"})).is_err()); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 48.0, "scale": 10_000_000, "pitch": 45, "bearing": 20})).unwrap(); assert_eq!((s.map().camera.pitch, s.map().camera.bearing), (45.0, 20.0)); assert_eq!(s.run("info.project", &json!({})).unwrap()["pitch"], json!(45.0)); // A bookmark and its file keep the pitch. s.run("bookmark.add", &json!({"name": "Tilted"})).unwrap(); let file = crate::cmd::navigate::bookmarks_file(s.map()).unwrap(); s.run("map.resetNorth", &json!({})).unwrap(); assert_eq!((s.map().camera.pitch, s.map().camera.bearing), (0.0, 0.0)); // Previous Extent tilts it back. s.run("map.prevExtent", &json!({})).unwrap(); assert_eq!(s.map().camera.pitch, 45.0); s.run("map.resetNorth", &json!({})).unwrap(); s.run("bookmark.remove", &json!({"name": "Tilted"})).unwrap(); s.run("bookmark.import", &json!({"text": String::from_utf8(file).unwrap()})).unwrap(); s.run("bookmark.go", &json!({"name": "Tilted"})).unwrap(); assert!((s.map().camera.pitch - 45.0).abs() < 1e-9); // A tilted map exports. let dir = std::env::temp_dir().join(format!("rustgis-tilt-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("tilted.png"); s.run("share.exportMap", &json!({"path": png.display().to_string(), "width": 320, "height": 200})).unwrap(); assert!(std::fs::metadata(&png).unwrap().len() > 1000); let _ = std::fs::remove_dir_all(&dir); } /// The bookmark manager's commands (#351): names stay unique, a bookmark is renamed, updated, /// described and moved in place, Pan To keeps the scale, and any map's bookmarks are reachable. #[test] fn bookmarks_are_managed_in_place_on_any_map() { let mut s = world(); let names = |s: &Session| s.map().bookmarks.iter().map(|b| b.name.clone()).collect::>(); let first = names(&s); assert!(first.contains(&"Europe".to_string()), "{first:?}"); // A taken name is numbered instead of replacing the bookmark that has it. assert_eq!(s.run("bookmark.add", &json!({"name": "europe"})).unwrap()["name"], "europe 2"); assert_eq!(s.run("bookmark.add", &json!({"name": "Europe"})).unwrap()["name"], "Europe 3"); assert_eq!(names(&s).len(), first.len() + 2); // A long name that is taken keeps its number within the 120 characters. let long = "x".repeat(150); let mut longs = Vec::new(); for _ in 0..2 { longs.push(s.run("bookmark.add", &json!({"name": long})).unwrap()["name"].as_str().unwrap().to_string()); } assert_eq!(longs.iter().map(|n| n.chars().count()).collect::>(), [120, 120]); assert!(longs[1].ends_with(" 2") && longs[0] != longs[1], "{longs:?}"); for n in &longs { s.run("bookmark.remove", &json!({"name": n})).unwrap(); } // Rename: unique among the others, its own name in another case is fine, one undo step. assert_eq!(s.run("bookmark.rename", &json!({"name": "Europe 3", "newName": "World"})).unwrap()["name"], "World 2"); assert_eq!(s.run("bookmark.rename", &json!({"name": "World 2", "newName": "WORLD 2"})).unwrap()["name"], "WORLD 2"); s.run("edit.undo", &Value::Null).unwrap(); assert!(names(&s).contains(&"World 2".to_string())); assert!(s.run("bookmark.rename", &json!({"name": "Nowhere", "newName": "X"})).is_err()); assert!(s.run("bookmark.rename", &json!({"name": "World 2", "newName": " "})).is_err()); // Update takes the current view, keeping the bookmark's place in the list. let at = names(&s).iter().position(|n| n == "Africa"); s.run("map.goToXY", &json!({"lon": 100.0, "lat": -5.0, "scale": 2_000_000})).unwrap(); s.run("bookmark.update", &json!({"name": "africa"})).unwrap(); assert_eq!(names(&s).iter().position(|n| n == "Africa"), at); assert_eq!(s.map().bookmarks.iter().find(|b| b.name == "Africa").map(|b| b.camera), Some(s.map().camera)); // A description, trimmed and kept to 2000 characters; empty clears it. s.run("bookmark.describe", &json!({"name": "Africa", "description": " Indonesia, really "})).unwrap(); assert_eq!(s.map().bookmarks.iter().find(|b| b.name == "Africa").map(|b| b.description.as_str()), Some("Indonesia, really")); s.run("bookmark.describe", &json!({"name": "Africa", "description": "é".repeat(3000)})).unwrap(); assert_eq!(s.map().bookmarks.iter().find(|b| b.name == "Africa").map(|b| b.description.chars().count()), Some(2000)); // Move: to the front, and past the end to the back. s.run("bookmark.move", &json!({"name": "Africa", "to": 0})).unwrap(); assert_eq!(names(&s).first().map(String::as_str), Some("Africa")); s.run("bookmark.move", &json!({"name": "Africa", "to": 999})).unwrap(); assert_eq!(names(&s).last().map(String::as_str), Some("Africa")); for p in [json!({"name": "Africa"}), json!({"name": "Africa", "to": -1}), json!({"name": "Africa", "to": "x"})] { assert!(s.run("bookmark.move", &p).is_err(), "{p}"); } // Pan To moves to the place at the current scale; Go To takes the bookmark's scale too. s.run("map.goToXY", &json!({"lon": 0.0, "lat": 0.0, "scale": 40_000_000})).unwrap(); let scale = s.scale(); s.run("bookmark.go", &json!({"name": "Europe", "pan": true})).unwrap(); assert!((s.scale() / scale - 1.0).abs() < 1e-6, "Pan To keeps the scale"); s.run("bookmark.go", &json!({"name": "Europe"})).unwrap(); assert!((s.scale() / scale - 1.0).abs() > 0.01, "Go To takes the bookmark's scale"); // Another map's bookmark is reached by `map`. let world_map = s.map().name.clone(); s.run("map.new", &json!({"name": "Second"})).unwrap(); assert!(s.run("bookmark.go", &json!({"name": "Europe"})).is_err(), "not on this map"); s.run("bookmark.add", &json!({"name": "Here", "map": world_map, "description": "added from Second"})).unwrap(); // Going to another map's bookmark moves the map in view to its place (as desktop GIS do): Second // stays in view, at the place and scale World shows for it. s.run("bookmark.go", &json!({"name": "Here", "map": world_map})).unwrap(); assert_eq!(s.map().name, "Second"); let (there, there_scale) = (s.map().center_lonlat(), s.scale()); assert!(s.run("bookmark.go", &json!({"name": "Here", "map": 0})).is_err(), "a map that isn't a name"); // Update takes the view on screen, so only with the bookmark's map in view; describing works // from anywhere. assert!(s.run("bookmark.update", &json!({"name": "Here", "map": world_map})).is_err()); s.run("bookmark.describe", &json!({"name": "Here", "map": world_map, "description": "added from Second"})).unwrap(); s.run("map.activate", &json!({"name": world_map})).unwrap(); s.run("bookmark.go", &json!({"name": "Here"})).unwrap(); let here = s.map().center_lonlat(); assert!((here.x - there.x).abs() < 1e-6 && (here.y - there.y).abs() < 1e-6, "{here:?} {there:?}"); assert!((s.scale() / there_scale - 1.0).abs() < 0.02, "{} {there_scale}", s.scale()); // The description travels in a bookmarks file. let file = super::cmd::navigate::bookmarks_file(s.map()).unwrap(); let text = String::from_utf8(file).unwrap(); assert!(text.contains("added from Second"), "{text}"); s.run("bookmark.remove", &json!({"name": "Here"})).unwrap(); s.run("bookmark.import", &json!({"text": text})).unwrap(); let described = |s: &Session| s.map().bookmarks.iter().find(|b| b.name == "Here").map(|b| b.description.clone()); assert_eq!(described(&s).as_deref(), Some("added from Second")); // A file without descriptions (older, or written by hand) leaves the one a bookmark has; an // empty one clears it. let mut old: Value = serde_json::from_str(&text).unwrap(); for b in old["bookmarks"].as_array_mut().unwrap() { b.as_object_mut().unwrap().remove("description"); } s.run("bookmark.import", &json!({"text": old.to_string()})).unwrap(); assert_eq!(described(&s).as_deref(), Some("added from Second")); for b in old["bookmarks"].as_array_mut().unwrap() { b["description"] = json!(""); } s.run("bookmark.import", &json!({"text": old.to_string()})).unwrap(); assert_eq!(described(&s).as_deref(), Some("")); // Unnamed, a bookmark takes the next `Bookmark N` free, never `Bookmark N 2` (the New // Bookmark dialog offers the same name). let n = s.map().bookmarks.len(); s.run("bookmark.add", &json!({"name": format!("Bookmark {}", n + 2)})).unwrap(); assert_eq!(super::cmd::navigate::new_bookmark_name(s.map()), format!("Bookmark {}", n + 3)); assert_eq!(s.run("bookmark.add", &Value::Null).unwrap()["name"], format!("Bookmark {}", n + 3)); // "Bookmark 03" isn't "Bookmark 3". s.run("bookmark.add", &json!({"name": format!("Bookmark 0{}", n + 4)})).unwrap(); assert_eq!(super::cmd::navigate::new_bookmark_name(s.map()), format!("Bookmark {}", n + 4)); // A map keeps at most 10,000 bookmarks, added or imported. let camera = s.map().camera; let marks = &mut s.map_mut().bookmarks; while marks.len() < 10_000 { let name = format!("Bookmark {}", marks.len() + 1); marks.push(rustgis_map::Bookmark::new(name, camera)); } assert!(s.run("bookmark.add", &Value::Null).is_err()); assert_eq!(s.map().bookmarks.len(), 10_000); } #[test] fn map_rename_names_any_map_and_what_shows_it_follows() { use rustgis_map::layout::ElementKind; use rustgis_map::presentation::PageKind; let mut s = world(); let first = s.map().name.clone(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); s.run("presentation.new", &Value::Null).unwrap(); s.run("presentation.mapPage", &Value::Null).unwrap(); s.run("offline.download", &json!({"name": "Paris", "extent": [1.0, 48.0, 4.0, 50.0]})).unwrap(); s.run("map.new", &json!({"name": "Second"})).unwrap(); // The Browser renames a map that isn't in view, which stays out of view. let r = s.run("map.rename", &json!({"map": first.to_uppercase(), "name": "Atlas"})).unwrap(); assert_eq!((r["name"].as_str(), s.map().name.as_str()), (Some("Atlas"), "Second")); let shown = |s: &Session| { let frames: Vec = s.project.layouts[0] .elements .iter() .filter_map(|e| match &e.kind { ElementKind::MapFrame { map, .. } => Some(map.clone()), _ => None, }) .collect(); let pages: Vec = s.project.presentations[0] .pages .iter() .filter_map(|p| match &p.kind { PageKind::Map { map, .. } => Some(map.clone()), _ => None, }) .collect(); (frames, pages, s.project.offline_areas[0].map.clone()) }; assert_eq!(shown(&s), (vec!["Atlas".to_string()], vec!["Atlas".to_string()], "Atlas".to_string())); // One undo step puts the name back everywhere. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(shown(&s), (vec![first.clone()], vec![first.clone()], first.clone())); assert!(s.project.maps.iter().any(|m| m.name == first)); assert!(s.run("map.rename", &json!({"map": "Nowhere", "name": "X"})).is_err()); // A selector that isn't a name never falls back to the active map. for m in [json!(0), json!(true), json!(["Atlas"])] { assert!(s.run("map.rename", &json!({"map": m, "name": "X"})).is_err(), "{m}"); } assert_eq!(s.map().name, "Second"); } /// Compares all project state except the geometry cache revision invalidated by undo. fn renamed_project_state(s: &Session) -> Value { let mut value = serde_json::to_value(&s.project).unwrap(); for map in value["maps"].as_array_mut().unwrap() { for layer in map["layers"].as_array_mut().unwrap() { layer.as_object_mut().unwrap().remove("geometry_rev"); } } value } /// Everything a project keeps that names a layer follows the layer's rename, as the map frames /// follow a map's. #[test] fn what_names_a_layer_follows_its_rename() { /// Collects reference spellings from each typed item's JSON without depending on its shape. fn strings(v: &Value, out: &mut Vec) { match v { Value::String(t) => out.push(t.clone()), Value::Array(a) => a.iter().for_each(|v| strings(v, out)), Value::Object(o) => o.values().for_each(|v| strings(v, out)), _ => {} } } let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); let li = s.active_layout.unwrap(); let mut elements = vec![("legend exclude", s.project.layouts[li].elements.iter().find(|e| e.name == "Legend").unwrap().id)]; s.run("layout.update", &json!({"element": elements[0].1, "exclude": ["Countries"]})).unwrap(); for (what, el) in [ ("layout table", json!({"kind": "table", "layer": "Countries"})), ("layout chart", json!({"kind": "chart", "layer": "Countries", "field": "pop_est", "label": "name"})), ("layout colorbar", json!({"kind": "colorbar", "layer": "dem"})), ] { let id = s.run("layout.addElement", &el).unwrap()["element"].as_u64().unwrap() as u32; elements.push((what, id)); } s.run("layout.series", &json!({"layer": "Countries", "nameField": "name"})).unwrap(); s.run("layer.mask", &json!({"layer": "Cities", "mask": "Countries"})).unwrap(); s.run("map.terrain", &json!({"layer": "dem"})).unwrap(); s.run("report.new", &json!({"layer": "Countries", "name": "Report", "fields": ["name"]})).unwrap(); let graph = json!({ "nodes": [ {"id": "i1", "kind": "input", "x": 0, "y": 0, "layer": "Countries"}, {"id": "o1", "kind": "output", "x": 240, "y": 0, "name": "Kept"} ], "edges": [{"from": "i1", "fromPort": "out", "to": "o1", "toPort": "in"}] }); s.run("model.new", &json!({"name": "Model", "graph": graph})).unwrap(); s.run("data.relate", &json!({"name": "Country of city", "origin": "Countries", "originKey": "name", "destination": "Cities", "destinationKey": "adm0name"})) .unwrap(); s.run("topology.rules", &json!({"add": {"rule": "mustBeCoveredBy", "layer": "Cities", "by": "Countries"}})).unwrap(); s.run("edit.elevation", &json!({"mode": "surface", "surface": "dem"})).unwrap(); s.run("imagery.workspace", &json!({"kind": "reality", "name": "Heights", "images": ["dem"]})).unwrap(); let before = renamed_project_state(&s); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); s.run("layer.rename", &json!({"layer": "dem", "name": "Elevation"})).unwrap(); let mut refs: Vec<(&str, Value)> = Vec::new(); for (what, id) in &elements { let e = s.project.layouts[li].elements.iter().find(|e| e.id == *id).unwrap(); // A colorbar's title is literal display text, not a layer reference. let reference = match &e.kind { rustgis_map::layout::ElementKind::Colorbar { layer, .. } => json!(layer), _ => serde_json::to_value(&e.kind).unwrap(), }; refs.push((what, reference)); } let series = s.project.layouts[li].series.as_ref().map(|m| m.layer.clone()); refs.push(("map series", json!(series))); refs.push(("mask", json!(s.map().layer_by_name("Cities").unwrap().mask))); refs.push(("terrain", serde_json::to_value(&s.map().view.terrain).unwrap())); refs.push(("report", json!(s.project.reports[0].layer))); let model = s.project.models.iter().find(|m| m.name == "Model").unwrap(); refs.push(("model input", serde_json::to_value(&model.graph.as_ref().unwrap().nodes[0]).unwrap())); refs.push(("relationship", serde_json::to_value(&s.map().editing.relationships[0]).unwrap())); refs.push(("topology rule", serde_json::to_value(&s.map().editing.topology).unwrap())); refs.push(("edit elevation", serde_json::to_value(&s.map().editing.settings.elevation).unwrap())); refs.push(("imagery workspace", json!(s.project.workspaces[0].images))); let stale: Vec = refs .iter() .filter_map(|(what, v)| { let mut names = Vec::new(); strings(v, &mut names); let old = names.iter().find(|n| *n == "Countries" || *n == "dem")?; Some(format!("{what} still names '{old}'")) }) .collect(); assert!(stale.is_empty(), "{} of {} references kept the old name:\n{}", stale.len(), refs.len(), stale.join("\n")); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before, "undo restores every reference with its layer name"); } /// A layout reference follows only the map its frame shows; a project-wide reference without /// a map stays put, with a notice, when the old name also exists in a second map. #[test] fn layer_rename_keeps_references_in_their_maps() { use rustgis_map::layout::ElementKind; let mut s = world(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); s.run("layout.addElement", &json!({"kind": "table", "layer": "Countries"})).unwrap(); s.run("layout.series", &json!({"layer": "Countries", "nameField": "name"})).unwrap(); s.run("report.new", &json!({"layer": "Countries", "name": "Report", "fields": ["name"]})).unwrap(); let mut second = s.map().clone(); second.name = "Second".into(); s.project.maps.push(second); let mut second_layout = s.project.layouts[0].clone(); second_layout.name = "Second layout".into(); for element in &mut second_layout.elements { if let ElementKind::MapFrame { map, .. } = &mut element.kind { *map = "Second".into(); } } s.project.layouts.push(second_layout); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); for (layout, expected) in s.project.layouts.iter().zip(["Nations", "Countries"]) { assert_eq!(layout.series.as_ref().unwrap().layer, expected); for element in &layout.elements { if let ElementKind::Table { layer, .. } = &element.kind { assert_eq!(layer, expected); } } } assert_eq!(s.project.reports[0].layer, "Countries"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("another map has a layer of the old or new name"))); } #[test] fn layer_rename_never_redirects_a_table_to_another_layer_of_the_new_name() { use rustgis_map::layout::ElementKind; let mut s = world(); let cities = s.map().layer_by_name("Cities").unwrap().id; s.run("layout.new", &json!({"page": "a4"})).unwrap(); let id = s.run("layout.addElement", &json!({"kind": "table", "layer": "Cities"})).unwrap()["element"].as_u64().unwrap() as u32; let before = renamed_project_state(&s); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": cities, "name": "Countries"})).unwrap(); let table = s.project.layouts[0].elements.iter().find(|e| e.id == id).unwrap(); let ElementKind::Table { layer, .. } = &table.kind else { panic!("not a table") }; assert_eq!(layer, "Cities"); assert!(s.map().layer_by_name(layer).is_none(), "a stale reference must never display another layer's rows"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("two layers are now called 'Countries'"))); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before); assert_eq!(s.map().layer_by_name("Cities").unwrap().id, cities); } /// Case-only renames keep exact-spelling selectors current without changing other maps. #[test] fn layer_rename_keeps_case_sensitive_selectors_current() { use rustgis_map::design::Elevation; let mut s = world(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); s.run("layout.series", &json!({"layer": "Countries", "nameField": "name"})).unwrap(); let mut other_layout = s.project.layouts[0].clone(); for element in &mut other_layout.elements { if let rustgis_map::layout::ElementKind::MapFrame { map, .. } = &mut element.kind { *map = "Second".into(); } } s.project.layouts.push(other_layout); s.map_mut().editing.settings.elevation = Elevation::Surface("Countries".into()); let before = renamed_project_state(&s); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": "Countries", "name": "countries"})).unwrap(); assert_eq!(s.map().editing.settings.elevation, Elevation::Surface("countries".into())); assert_eq!(s.project.layouts[0].series.as_ref().unwrap().layer, "countries"); assert_eq!(s.project.layouts[1].series.as_ref().unwrap().layer, "Countries"); assert!(s.notices_since(mark).is_empty()); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before); } /// References already using the destination name need a notice when lookup becomes ambiguous. #[test] fn layer_rename_reports_references_already_using_a_duplicate_destination() { for other_map in [false, true] { let mut s = world(); let (layer, new, expected) = if other_map { let mut second = s.map().clone(); second.name = "Second".into(); second.layers.iter_mut().find(|l| l.name == "Countries").unwrap().name = "Nations".into(); s.project.maps.push(second); ("Countries", "Nations", 1) } else { s.run("layer.mask", &json!({"layer": "Rivers", "mask": "Countries"})).unwrap(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); s.run("layout.addElement", &json!({"kind": "table", "layer": "Countries"})).unwrap(); ("Cities", "Countries", 3) }; s.run("report.new", &json!({"layer": new, "name": "Report", "fields": ["name"]})).unwrap(); let before = renamed_project_state(&s); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": layer, "name": new})).unwrap(); let notices: Vec<_> = s.notices_since(mark).iter().filter(|n| n.text.contains("already name")).collect(); assert_eq!(notices.len(), 1); assert!(notices[0].text.starts_with(&format!("{expected} layer or project reference(s)")), "{}", notices[0].text); assert_eq!(s.project.reports[0].layer, new); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": layer, "name": "Unique Name"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("already name"))); } } /// A project-wide report must not begin resolving to a different map after a rename. #[test] fn layer_rename_keeps_project_references_when_another_map_has_the_new_name() { let mut s = world(); s.run("report.new", &json!({"layer": "Countries", "name": "Report", "fields": ["name"]})).unwrap(); let mut second = s.map().clone(); second.name = "Second".into(); for layer in &mut second.layers { if layer.name == "Countries" { layer.name = "Nations".into(); } } s.project.maps.push(second); let before = renamed_project_state(&s); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); assert_eq!(s.project.reports[0].layer, "Countries"); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("1 project reference(s)") && n.text.contains("old or new name"))); s.project.active_map = 1; assert!(s.project.maps.iter().all(|map| map.layer_by_name(&s.project.reports[0].layer).is_none())); s.project.active_map = 0; s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before); } /// References left on a duplicate old name need a notice because the intended layer is unknown. #[test] fn layer_rename_says_when_references_keep_a_duplicate_old_name() { let mut s = world(); s.run("layer.mask", &json!({"layer": "Rivers", "mask": "Countries"})).unwrap(); let countries = s.map().layer_by_name("Countries").unwrap().id; s.run("layout.new", &json!({"page": "a4"})).unwrap(); let table = s.run("layout.addElement", &json!({"kind": "table", "layer": "Countries"})).unwrap()["element"].as_u64().unwrap() as u32; s.map_mut().layers.iter_mut().find(|layer| layer.name == "Cities").unwrap().name = "Countries".into(); let before = renamed_project_state(&s); let mark = s.notice_mark(); s.run("layer.rename", &json!({"layer": countries, "name": "Nations"})).unwrap(); let element = s.project.layouts[0].elements.iter().find(|element| element.id == table).unwrap(); let rustgis_map::layout::ElementKind::Table { layer, .. } = &element.kind else { panic!("not a table") }; assert_eq!(layer, "Countries"); assert_eq!(s.map().layer_by_name("Rivers").unwrap().mask.as_deref(), Some("Countries")); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("2 layer reference(s)") && n.text.contains("another layer in this map"))); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renamed_project_state(&s), before); } #[test] fn layout_elements_are_brought_to_the_front_and_sent_to_the_back() { let mut s = world(); s.run("layout.new", &json!({"name": "Order"})).unwrap(); let li = s.active_layout.unwrap(); let l = &s.project.layouts[li]; let frame = l.first_frame().unwrap(); let legend = l.elements.iter().find(|e| e.name == "Legend").unwrap().clone(); let order = |s: &Session, id: u32| s.project.layouts[li].draw_order(id).unwrap(); let n = order(&s, frame).1; // The legend moved over the map frame: where they overlap it's drawn on top and is picked. s.run("layout.update", &json!({"element": legend.id, "x": 30, "y": 30})).unwrap(); s.run("layout.arrange", &json!({"element": frame, "to": "front"})).unwrap(); assert_eq!(order(&s, frame), (n - 1, n)); assert_eq!(s.project.layouts[li].hit(31.0, 31.0), Some(frame), "the frame, on top, covers the legend"); let r = s.run("layout.arrange", &json!({"element": legend.id, "to": "front"})).unwrap(); assert_eq!((r["index"].as_u64(), r["of"].as_u64()), (Some(n as u64 - 1), Some(n as u64))); assert_eq!(s.project.layouts[li].hit(31.0, 31.0), Some(legend.id), "the legend over the map"); // One place at a time, and to the bottom. s.run("layout.arrange", &json!({"element": legend.id, "to": "backward"})).unwrap(); assert_eq!(order(&s, legend.id).0, n - 2); s.run("layout.arrange", &json!({"element": legend.id, "to": "back"})).unwrap(); assert_eq!(order(&s, legend.id).0, 0); s.run("layout.arrange", &json!({"element": legend.id, "to": "forward"})).unwrap(); assert_eq!(order(&s, legend.id).0, 1); // Bad values, and a locked element, change nothing. for bad in [json!({"element": legend.id, "to": "top"}), json!({"element": legend.id}), json!({"element": 999_999, "to": "front"})] { assert!(s.run("layout.arrange", &bad).is_err(), "{bad}"); } s.run("layout.update", &json!({"element": legend.id, "locked": true})).unwrap(); assert!(s.run("layout.arrange", &json!({"element": legend.id, "to": "front"})).is_err()); assert_eq!(order(&s, legend.id).0, 1); // Each move is an undo step. s.run("edit.undo", &Value::Null).unwrap(); // the lock s.run("edit.undo", &Value::Null).unwrap(); // forward assert_eq!(order(&s, legend.id).0, 0); // A move that changes nothing (back, for the element already at the bottom) succeeds but is // no step to undo. let depth = s.undo_depth(); let r = s.run("layout.arrange", &json!({"element": legend.id, "to": "back"})).unwrap(); assert_eq!(r["index"].as_u64(), Some(0)); assert_eq!(s.undo_depth(), depth, "no empty undo step"); } #[test] fn layout_elements_duplicate_offset_on_top_and_undo() { let mut s = world(); s.run("layout.new", &json!({"name": "D"})).unwrap(); let li = s.active_layout.unwrap(); let legend = s.project.layouts[li].elements.iter().find(|e| e.name == "Legend").unwrap().clone(); let r = s.run("layout.duplicateElement", &json!({"element": legend.id})).unwrap(); let id = r["element"].as_u64().unwrap() as u32; let l = &s.project.layouts[li]; let copy = l.element(id).unwrap(); // A new id and name, the same kind and size, offset 5 mm, drawn on top. assert_ne!(id, legend.id); assert_eq!((copy.name.as_str(), copy.x, copy.y, copy.w, copy.h), ("Legend 2", legend.x + 5.0, legend.y + 5.0, legend.w, legend.h)); assert_eq!(copy.kind, legend.kind); assert_eq!(l.elements.last().map(|e| e.id), Some(id)); // A given offset; the next copy takes the next free name. let r = s.run("layout.duplicateElement", &json!({"element": legend.id, "dx": 0, "dy": -20})).unwrap(); assert_eq!(r["name"], "Legend 3"); let third_id = r["element"].as_u64().unwrap() as u32; let third = s.project.layouts[li].element(third_id).unwrap(); assert_eq!((third.x, third.y), (legend.x, legend.y - 20.0)); for bad in [json!({}), json!({"element": 9999}), json!({"element": legend.id, "dx": 1e9})] { assert!(s.run("layout.duplicateElement", &bad).is_err(), "{bad}"); } s.run("edit.undo", &Value::Null).unwrap(); let l = &s.project.layouts[li]; assert!(l.element(third_id).is_none() && l.element(id).is_some(), "one undo removes the last copy only"); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.project.layouts[li].element(id).is_none(), "undone"); } #[test] fn locked_layout_elements_take_no_change_until_unlocked() { use rustgis_map::layout::ElementKind; let mut s = world(); s.run("layout.new", &json!({"name": "L"})).unwrap(); let li = s.active_layout.unwrap(); let frame = s.project.layouts[li].first_frame().unwrap(); let legend = s.project.layouts[li].elements.iter().find(|e| e.name == "Legend").unwrap().id; let title = s.run("layout.addElement", &json!({"kind": "text", "text": "Title", "x": 10, "y": 10})).unwrap()["element"] .as_u64() .unwrap() as u32; s.run("layout.update", &json!({"element": title, "locked": true})).unwrap(); let before = s.project.layouts[li].element(title).unwrap().clone(); assert!(before.locked); // Every change is refused, and the element stays as it was. for bad in [ json!({"x": 50}), json!({"w": 80, "h": 20}), json!({"text": "Oops"}), json!({"name": "T"}), json!({"visible": false}), json!({"locked": true, "x": 1}), ] { let mut p = bad.clone(); p["element"] = json!(title); let err = s.run("layout.update", &p).unwrap_err(); assert!(err.contains("locked"), "{bad}: {err}"); } assert_eq!(s.project.layouts[li].element(title), Some(&before)); assert!(s.run("layout.removeElement", &json!({"element": title})).is_err_and(|e| e.contains("locked"))); assert!(s.project.layouts[li].element(title).is_some()); // Locking it again, or a null value, changes nothing and isn't refused. s.run("layout.update", &json!({"element": title, "locked": true, "x": null})).unwrap(); // A copy can be made, and it isn't locked. let copy = s.run("layout.duplicateElement", &json!({"element": title})).unwrap()["element"].as_u64().unwrap() as u32; assert!(!s.project.layouts[li].element(copy).unwrap().locked); // Unlocking may come with other changes. s.run("layout.update", &json!({"element": title, "locked": false, "x": 50})).unwrap(); let e = s.project.layouts[li].element(title).unwrap(); assert_eq!((e.locked, e.x), (false, 50.0)); // Locking and moving at once: it moves, then locks. s.run("layout.update", &json!({"element": title, "x": 60, "locked": true})).unwrap(); let e = s.project.layouts[li].element(title).unwrap(); assert_eq!((e.locked, e.x), (true, 60.0)); // Undone, the lock goes back as any other change does. s.run("edit.undo", &Value::Null).unwrap(); assert!(!s.project.layouts[li].element(title).unwrap().locked); // A locked map frame keeps its extent and gets no grid or extent indicator… s.run("layout.update", &json!({"element": frame, "locked": true})).unwrap(); let kind = s.project.layouts[li].element(frame).unwrap().kind.clone(); assert!(s.run("layout.frameZoom", &json!({"element": frame, "to": "full"})).is_err_and(|e| e.contains("locked"))); assert!(s.run("layout.update", &json!({"element": frame, "scale": 1_000_000})).is_err_and(|e| e.contains("locked"))); assert!(s.run("layout.addElement", &json!({"kind": "grid", "frame": frame})).is_err_and(|e| e.contains("locked"))); let inset = s.run("layout.addFrame", &json!({"x": 150, "y": 150, "w": 40, "h": 40})).unwrap()["element"].as_u64().unwrap() as u32; assert!( s.run("layout.addElement", &json!({"kind": "extentIndicator", "frame": frame, "of": inset})).is_err_and(|e| e.contains("locked")) ); assert_eq!(s.project.layouts[li].element(frame).unwrap().kind, kind); // …but a map series still turns its pages. s.run("layout.update", &json!({"element": frame, "locked": false})).unwrap(); s.run("layout.series", &json!({"layer": "Lakes", "frame": frame})).unwrap(); s.run("layout.update", &json!({"element": frame, "locked": true})).unwrap(); let center = |s: &Session| match &s.project.layouts[li].element(frame).unwrap().kind { ElementKind::MapFrame { center, .. } => *center, _ => panic!("not a frame"), }; let first = center(&s); s.run("layout.seriesPage", &json!({"page": "next"})).unwrap(); assert_ne!(center(&s), first, "the series moved the locked frame"); // A frame whose surround is locked isn't deleted: the surround would go with it. s.run("layout.update", &json!({"element": frame, "locked": false})).unwrap(); s.run("layout.update", &json!({"element": legend, "locked": true})).unwrap(); let err = s.run("layout.removeElement", &json!({"element": frame})).unwrap_err(); assert!(err.contains("Legend"), "{err}"); assert!(s.project.layouts[li].element(frame).is_some() && s.project.layouts[li].element(legend).is_some()); // Saved with the lock, and a layout from before locks loads unlocked. let saved = serde_json::to_value(&s.project.layouts[li]).unwrap(); let back: rustgis_map::layout::Layout = serde_json::from_value(saved.clone()).unwrap(); assert_eq!(back, s.project.layouts[li]); let old = saved["elements"].as_array().unwrap().iter().find(|e| e["id"] == json!(copy)).unwrap(); assert!(old.get("locked").is_none(), "an unlocked element doesn't write the field"); let mut older = saved.clone(); for e in older["elements"].as_array_mut().unwrap() { e.as_object_mut().unwrap().remove("locked"); } let back: rustgis_map::layout::Layout = serde_json::from_value(older).unwrap(); assert!(back.elements.iter().all(|e| !e.locked)); } #[test] fn layout_shapes_are_added_restyled_and_saved() { use rustgis_map::layout::{ElementKind, LineDir}; let mut s = world(); s.run("layout.new", &json!({"name": "Shapes", "template": "blank"})).unwrap(); let li = s.active_layout.unwrap(); let add = |s: &mut Session, p: Value| s.run("layout.addElement", &p).unwrap()["element"].as_u64().unwrap() as u32; let rect = add(&mut s, json!({"kind": "rectangle", "x": 20, "y": 20, "w": 60, "h": 30})); let circle = add(&mut s, json!({"kind": "circle"})); let ellipse = add(&mut s, json!({"kind": "ellipse", "fill": "#FF000080"})); let line = add(&mut s, json!({"kind": "line", "dir": "vertical", "width": 3})); let l = &s.project.layouts[li]; let e = |id: u32| l.element(id).unwrap(); // A rectangle given a box is a shape; without one it's still the page's neatline. assert_eq!((e(rect).name.as_str(), e(rect).x, e(rect).w), ("Rectangle", 20.0, 60.0)); assert!(matches!(e(rect).kind, ElementKind::Rectangle { .. })); assert_eq!(e(circle).name, "Circle"); assert_eq!(e(circle).w, e(circle).h, "a circle's box is square"); assert!(e(ellipse).w > e(ellipse).h); assert!(matches!(e(ellipse).kind, ElementKind::Ellipse { fill, .. } if fill.a() == 0x80)); assert!(matches!(e(line).kind, ElementKind::Line { dir: LineDir::Vertical, width, .. } if width == 3.0)); assert!(e(line).h > e(line).w, "a vertical line's box is tall"); // Restyled through layout.update; the outline is "outline" or "color". s.run("layout.update", &json!({"element": circle, "fill": "#00FF00", "color": "#0000FF", "width": 0})).unwrap(); s.run("layout.update", &json!({"element": line, "dir": "rising", "color": "#123456"})).unwrap(); let l = &s.project.layouts[li]; match &l.element(circle).unwrap().kind { ElementKind::Ellipse { fill, outline, width } => { assert_eq!((fill.hex(), outline.hex(), *width), ("#00FF00".into(), "#0000FF".into(), 0.0)) } k => panic!("{k:?}"), } match &l.element(line).unwrap().kind { ElementKind::Line { color, width, dir, .. } => assert_eq!((color.hex(), *width, *dir), ("#123456".into(), 3.0, LineDir::Rising)), k => panic!("{k:?}"), } // Bad values change nothing. for bad in [ json!({"kind": "line", "dir": "sideways"}), json!({"kind": "line", "dir": 3}), json!({"kind": "line", "width": "thick"}), json!({"kind": "circle", "fill": "red"}), json!({"kind": "ellipse", "width": true}), ] { assert!(s.run("layout.addElement", &bad).is_err(), "{bad}"); } for bad in [ json!({"element": line, "dir": "up"}), json!({"element": line, "dir": 1}), json!({"element": circle, "width": "thick"}), json!({"element": rect, "outline": "#XYZ"}), ] { assert!(s.run("layout.update", &bad).is_err(), "{bad}"); } assert!(matches!(s.project.layouts[li].element(line).unwrap().kind, ElementKind::Line { dir: LineDir::Rising, .. })); // A null width or direction is left as it is. s.run("layout.update", &json!({"element": line, "width": null, "dir": null})).unwrap(); assert!(matches!(s.project.layouts[li].element(line).unwrap().kind, ElementKind::Line { dir: LineDir::Rising, width, .. } if width == 3.0)); // Any part of a box makes a rectangle a shape, not the page's neatline. let partial = add(&mut s, json!({"kind": "rectangle", "x": 30, "y": 40, "h": 10})); let e = s.project.layouts[li].element(partial).unwrap(); assert_eq!((e.name.as_str(), e.x, e.y, e.h), ("Rectangle", 30.0, 40.0, 10.0)); for p in [json!({"kind": "rectangle"}), json!({"kind": "rectangle", "x": null, "w": null})] { let neat = add(&mut s, p); assert_eq!(s.project.layouts[li].element(neat).unwrap().name, "Neatline"); } // Saved and read back. let back: rustgis_map::layout::Layout = serde_json::from_value(serde_json::to_value(&s.project.layouts[li]).unwrap()).unwrap(); assert_eq!(back, s.project.layouts[li]); } #[test] fn layout_arrows_point_turn_and_keep_their_length() { use rustgis_map::layout::{ElementKind, LineHead, line_angle, line_length}; let mut s = world(); s.run("layout.new", &json!({"name": "Arrows", "template": "blank"})).unwrap(); let li = s.active_layout.unwrap(); // The angle, length, heads and name of a line element. let read = |s: &Session, id: u32| { let e = s.project.layouts[li].element(id).unwrap(); match e.kind { ElementKind::Line { dir, head, .. } => { let r = (e.x, e.y, e.w, e.h); (line_angle(r, dir, head), line_length(r, dir), head, e.name.clone(), (e.x + e.w / 2.0, e.y + e.h / 2.0)) } ref k => panic!("{k:?}"), } }; let id = s.run("layout.addElement", &json!({"kind": "arrow", "angle": 45, "length": 50})).unwrap()["element"].as_u64().unwrap() as u32; let (a, len, head, name, centre) = read(&s, id); assert!((a - 45.0).abs() < 1e-9 && (len - 50.0).abs() < 1e-9, "{a}° {len} mm"); assert_eq!((head, name.as_str()), (LineHead::End, "Arrow")); // Turned to point left: the head moves to the line's start; length and centre stay. s.run("layout.update", &json!({"element": id, "angle": 180})).unwrap(); let (a, len, head, _, c) = read(&s, id); assert!((a.abs() - 180.0).abs() < 1e-9 && (len - 50.0).abs() < 1e-9, "{a}° {len} mm"); assert_eq!(head, LineHead::Start); assert!((c.0 - centre.0).abs() < 1e-9 && (c.1 - centre.1).abs() < 1e-9); // Made longer, it keeps pointing the same way. s.run("layout.update", &json!({"element": id, "length": 80})).unwrap(); let (a, len, ..) = read(&s, id); assert!((a.abs() - 180.0).abs() < 1e-9 && (len - 80.0).abs() < 1e-9); // Two heads, then none: a plain line. s.run("layout.update", &json!({"element": id, "head": "both"})).unwrap(); assert_eq!(read(&s, id).2, LineHead::Both); // A plain line takes an angle too; other elements don't. let line = s.run("layout.addElement", &json!({"kind": "line", "angle": -30})).unwrap()["element"].as_u64().unwrap() as u32; assert_eq!(read(&s, line).3, "Line"); let rect = s.run("layout.addElement", &json!({"kind": "rectangle", "w": 20, "h": 20})).unwrap()["element"].as_u64().unwrap() as u32; for bad in [ json!({"element": rect, "angle": 10}), json!({"element": id, "head": "sideways"}), json!({"element": id, "angle": "steep"}), json!({"element": id, "length": 0}), json!({"element": id, "length": -5}), ] { assert!(s.run("layout.update", &bad).is_err(), "{bad}"); } assert!(s.run("layout.addElement", &json!({"kind": "arrow", "head": "up"})).is_err()); assert_eq!(read(&s, id).2, LineHead::Both, "bad values changed nothing"); // Undo turns it back. s.run("edit.undo", &Value::Null).unwrap(); // the rectangle s.run("edit.undo", &Value::Null).unwrap(); // the line s.run("edit.undo", &Value::Null).unwrap(); // both heads s.run("edit.undo", &Value::Null).unwrap(); // the length let (a, len, ..) = read(&s, id); assert!((a.abs() - 180.0).abs() < 1e-9 && (len - 50.0).abs() < 1e-9); } #[test] fn layout_arrows_keep_shallow_and_steep_angles_exactly() { use rustgis_map::layout::{ElementKind, line_angle, line_length}; let mut s = world(); s.run("layout.new", &json!({"name": "Arrows", "template": "blank"})).unwrap(); let li = s.active_layout.unwrap(); let read = |s: &Session, id: u32| { let e = s.project.layouts[li].element(id).unwrap(); match e.kind { ElementKind::Line { dir, head, .. } => { let r = (e.x, e.y, e.w, e.h); (line_angle(r, dir, head), line_length(r, dir), (e.x, e.y)) } ref k => panic!("{k:?}"), } }; let add = |s: &mut Session, p: Value| s.run("layout.addElement", &p).unwrap()["element"].as_u64().unwrap() as u32; // A box under 5 mm one way is not stretched to the minimum a dragged element gets. for (angle, length) in [(3.0, 60.0), (85.0, 50.0), (-2.0, 40.0)] { let id = add(&mut s, json!({"kind": "arrow", "angle": angle, "length": length})); let (a, len, _) = read(&s, id); assert!((a - angle).abs() < 1e-9 && (len - length).abs() < 1e-9, "{angle}°: {a}° {len} mm"); } // x and y move an angled line without bending it, when added or turned. let id = add(&mut s, json!({"kind": "arrow", "angle": 30, "length": 40, "x": 12, "y": 34})); assert_eq!(read(&s, id).2, (12.0, 34.0)); s.run("layout.update", &json!({"element": id, "angle": 60, "x": 50})).unwrap(); let (a, len, (x, _)) = read(&s, id); assert!((a - 60.0).abs() < 1e-9 && (len - 40.0).abs() < 1e-9 && x == 50.0, "{a}° {len} mm at {x}"); // A length is more than 0 when added too, and a dir doesn't come with an angle or a length. assert!(s.run("layout.addElement", &json!({"kind": "arrow", "angle": 10, "length": 0})).is_err()); assert!(s.run("layout.addElement", &json!({"kind": "line", "angle": 10, "dir": "falling"})).is_err()); for bad in [json!({"element": id, "dir": "rising", "angle": 10}), json!({"element": id, "dir": "falling", "length": 30})] { assert!(s.run("layout.update", &bad).is_err(), "{bad}"); } } #[test] fn merged_undo_steps_undo_as_one() { let mut s = world(); s.run("layout.new", &json!({"name": "Merge"})).unwrap(); let li = s.active_layout.unwrap(); let title = s.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); let at = |s: &Session| s.project.layouts[li].element(title.id).map(|e| (e.x, e.y)).unwrap(); let nudge = |s: &mut Session, key: &str, x: f64| { s.merge_undo(key); s.run("layout.update", &json!({"element": title.id, "x": x})).unwrap(); }; let depth = s.undo_depth(); // A run under one key is one step. for x in [10.0, 11.0, 12.0] { nudge(&mut s, "a", x); } assert_eq!(s.undo_depth(), depth + 1); // A change in between starts a new step, even under the same key; so does another key. s.run("layout.update", &json!({"element": title.id, "y": 5.0})).unwrap(); nudge(&mut s, "a", 13.0); nudge(&mut s, "b", 14.0); assert_eq!(s.undo_depth(), depth + 4); // Undoing a run goes back to before its first change. for _ in 0..4 { s.run("edit.undo", &Value::Null).unwrap(); } assert_eq!(at(&s), (title.x, title.y)); // After an undo the same key starts afresh, not merging into the step before. nudge(&mut s, "a", 20.0); nudge(&mut s, "a", 21.0); assert_eq!(s.undo_depth(), depth + 1); // A key asked for by a command that failed is gone after it: the next change is its own step. s.merge_undo("a"); assert!(s.run("layout.update", &json!({"element": 999_999, "x": 1})).is_err()); s.run("layout.update", &json!({"element": title.id, "x": 30.0})).unwrap(); assert_eq!(s.undo_depth(), depth + 2); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(at(&s).0, 21.0); // So is one asked for by a command refused before it ran (unknown, blocked). nudge(&mut s, "c", 40.0); s.merge_undo("c"); assert!(s.run("no.such.command", &Value::Null).is_err()); let before = s.undo_depth(); s.run("layout.update", &json!({"element": title.id, "x": 41.0})).unwrap(); assert_eq!(s.undo_depth(), before + 1); // Failed admission, including the plugin branch, also consumes the next-command merge key. let mut refused_merges = Vec::new(); for (index, id) in ["map.addTiles", "plugin:missing-merge-test"].into_iter().enumerate() { let x = 50.0 + index as f64 * 10.0; nudge(&mut s, "refused", x); s.merge_undo("refused"); let denied = s .with_scope(crate::policy::Scope::peer(), |s| s.run(id, &json!({"url":"https://merge-refusal.invalid/{z}/{x}/{y}.png"}))) .unwrap(); assert!(denied.is_err(), "the command must be refused before its handler runs"); let before = s.undo_depth(); s.run("layout.update", &json!({"element":title.id,"x":x+1.0})).unwrap(); let separate = s.undo_depth() == before + 1; s.run("edit.undo", &Value::Null).unwrap(); refused_merges.push((id, separate, at(&s).0 == x)); } assert!( refused_merges.iter().all(|(_, separate, restored)| *separate && *restored), "failed admission reused its merge key: {refused_merges:?}" ); } #[test] fn layout_guides_are_set_kept_and_undone() { let mut s = world(); s.run("layout.new", &json!({"name": "G"})).unwrap(); let li = s.active_layout.unwrap(); let r = s.run("layout.guides", &json!({"x": [20, 148.5], "y": [30]})).unwrap(); assert_eq!(r, json!({"x": [20.0, 148.5], "y": [30.0]})); // A list given replaces that direction only. s.run("layout.guides", &json!({"y": [10, 40]})).unwrap(); let g = s.project.layouts[li].guides.clone(); assert_eq!((g.x, g.y), (vec![20.0, 148.5], vec![10.0, 40.0])); // Bad positions change nothing. for bad in [json!({"x": [1, "a"]}), json!({"x": [1e9]}), json!({"y": 5}), json!({"x": vec![1.0; 65]})] { assert!(s.run("layout.guides", &bad).is_err(), "{bad}"); } assert_eq!(s.project.layouts[li].guides.y, vec![10.0, 40.0]); // Saved with the layout, and only when there are some. let saved = serde_json::to_value(&s.project.layouts[li]).unwrap(); assert_eq!(saved["guides"]["x"], json!([20.0, 148.5])); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.layouts[li].guides.y, vec![30.0], "undo restores the guides before"); s.run("layout.guides", &json!({"clear": true})).unwrap(); assert!(s.project.layouts[li].guides.is_empty()); assert!(serde_json::to_value(&s.project.layouts[li]).unwrap().get("guides").is_none()); } #[test] fn layout_names_keep_to_200_characters_with_their_number() { let mut s = world(); let long = "é".repeat(300); s.run("layout.new", &json!({"name": long})).unwrap(); let r = s.run("layout.new", &json!({"name": long})).unwrap(); let name = r["name"].as_str().unwrap_or_default().to_string(); assert_eq!((name.chars().count(), name.ends_with(" 2")), (200, true), "{name}"); s.run("layout.new", &json!({"name": "Other"})).unwrap(); let r = s.run("layout.rename", &json!({"name": "Other", "newName": long})).unwrap(); let name = r["name"].as_str().unwrap_or_default(); assert_eq!((name.chars().count(), name.ends_with(" 3")), (200, true), "{name}"); } /// Layers › Copy and Paste (#491): whole layers and groups, into another map or the same one. #[test] fn layers_are_copied_and_pasted_between_maps() { let mut s = world(); let first = s.map().name.clone(); let names = |m: &rustgis_map::Map| m.layers.iter().map(|l| l.name.clone()).collect::>(); assert!(s.run("layer.paste", &Value::Null).is_err(), "nothing copied yet"); // A group of two styled layers, one masked by the other, with features selected. s.run("group.add", &json!({"name": "Places", "layers": ["Cities", "Countries"]})).unwrap(); s.run("layer.mask", &json!({"layer": "Cities", "mask": "Countries"})).unwrap(); s.run("layer.definitionQuery", &json!({"layer": "Cities", "where": "pop_max > 1000000"})).unwrap(); s.map_mut().layers.iter_mut().find(|l| l.name == "Cities").unwrap().transparency = 40.0; s.run("select.byAttributes", &json!({"layer": "Cities", "where": "name = 'Paris'"})).unwrap(); let r = s.run("layer.copy", &json!({"group": "Places"})).unwrap(); assert_eq!(r["layers"], json!(["Cities", "Countries"]), "in drawing order"); // Pasted into an empty map: the same order, group, mask and style, no selection; one undo step. s.run("map.new", &json!({"name": "Second"})).unwrap(); let r = s.run("layer.paste", &Value::Null).unwrap(); assert_eq!((names(s.map()), r["unmasked"].clone()), (vec!["Cities".to_string(), "Countries".into()], json!([]))); assert_eq!(s.active_layer, s.map().layers.first().map(|l| l.id)); let m = s.map(); let cities = m.layer_by_name("Cities").unwrap(); assert_eq!((cities.mask.as_deref(), cities.transparency, cities.selection.len()), (Some("Countries"), 40.0, 0)); assert_eq!(cities.definition_query.as_deref(), Some("pop_max > 1000000")); assert_eq!((m.groups.len(), m.group_of(cities.id).map(|g| g.name.as_str())), (1, Some("Places"))); assert!(cities.dataset.features.shares(&s.project.maps[0].layer_by_name("Cities").unwrap().dataset.features), "shared until edited"); // Again into the same map: unique names, the mask follows its pasted layer, on top. s.run("layer.paste", &Value::Null).unwrap(); assert_eq!(names(s.map()), ["Cities copy", "Countries copy", "Cities", "Countries"]); let m = s.map(); let copy = m.layer_by_name("Cities copy").unwrap(); assert_eq!((copy.mask.as_deref(), m.group_of(copy.id).map(|g| g.name.as_str())), (Some("Countries copy"), Some("Places copy"))); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!((s.map().layers.len(), s.map().groups.len()), (2, 1)); // One layer, into a map out of view (the view stays), then into a map without its mask. s.run("map.activate", &json!({"name": first})).unwrap(); s.run("layer.copy", &json!({"layer": "Cities"})).unwrap(); s.run("layer.paste", &json!({"map": "Second"})).unwrap(); assert_eq!(s.map().name, first); let second = &s.project.maps[1]; let copy = second.layer_by_name("Cities copy").unwrap(); assert_eq!((copy.mask.as_deref(), second.group_of(copy.id).is_none()), (Some("Countries"), true)); s.run("map.new", &json!({"name": "Third"})).unwrap(); let r = s.run("layer.paste", &Value::Null).unwrap(); assert_eq!((r["unmasked"].clone(), s.map().layers[0].mask.clone()), (json!(["Cities"]), None)); // Only a polygon layer masks (as the renderer finds it): a point layer of that name doesn't. s.run("layer.rename", &json!({"layer": "Cities", "name": "Countries"})).unwrap(); let r = s.run("layer.paste", &Value::Null).unwrap(); assert_eq!((r["unmasked"].clone(), s.map().layers[0].mask.clone()), (json!(["Cities"]), None)); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); // Into a group, at its top. s.run("layer.paste", &json!({"map": "Second", "group": "Places"})).unwrap(); let second = &s.project.maps[1]; let group = second.groups.iter().find(|g| g.name == "Places").unwrap(); let top = second.layers.iter().find(|l| group.children.contains(&l.id)).map(|l| l.name.as_str()); assert_eq!((group.children.len(), top), (3, Some("Cities copy 2"))); // A group and a position at once is refused as such (Second has the group). let both = s.run("layer.paste", &json!({"map": "Second", "group": "Places", "to": 0})).unwrap_err(); assert!(both.contains("not both"), "{both}"); for bad in [json!({"map": "Nowhere"}), json!({"group": "Nowhere"}), json!({"to": -1})] { assert!(s.run("layer.paste", &json!(bad)).is_err(), "{bad}"); } for bad in [json!({"layer": "Nowhere"}), json!({"group": "Nowhere"}), json!({"layer": [1, "Nowhere"]}), json!({"layer": []})] { assert!(s.run("layer.copy", &bad).is_err(), "{bad}"); } // A copy that fails, an empty one included, keeps the last copy. let kept: Vec = s.layer_clipboard.as_ref().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(); assert_eq!(kept, ["Cities"]); // All or nothing: a failure after the layers went in (a later step of a task) takes them back. let (layers, groups) = (names(&s.project.maps[1]), s.project.maps[1].groups.len()); let r = s.guarded("Task", true, |s| { s.run("layer.paste", &json!({"map": "Second"}))?; Err("a later step failed".into()) }); assert!(r.is_err()); assert_eq!((names(&s.project.maps[1]), s.project.maps[1].groups.len()), (layers, groups)); // An empty group is copied too, and its paste is said as a group (no layers came with it). s.run("group.add", &json!({"name": "Empty"})).unwrap(); s.run("layer.copy", &json!({"group": "Empty"})).unwrap(); let r = s.run("layer.paste", &Value::Null).unwrap(); assert_eq!((r["layers"].clone(), r["groups"].as_array().map(Vec::len)), (json!([]), Some(1))); assert_eq!(s.notices.last().map(|n| n.text.as_str()), Some("Pasted 1 group into Third")); } #[test] fn map_duplicate_copies_a_map_beside_it_and_leaves_layouts_alone() { use rustgis_map::layout::ElementKind; let mut s = world(); let first = s.map().name.clone(); let layers = s.map().layers.len(); s.run("layout.new", &json!({"page": "a4"})).unwrap(); s.run("select.byAttributes", &json!({"layer": "Cities", "where": "name = 'Paris'"})).unwrap(); s.run("map.new", &json!({"name": "Second"})).unwrap(); // A map out of view, copied beside it and brought into view. let r = s.run("map.duplicate", &json!({"map": first})).unwrap(); let copy = format!("{first} copy"); assert_eq!(r["name"], copy.as_str()); let names: Vec<&str> = s.project.maps.iter().map(|m| m.name.as_str()).collect(); assert_eq!(names, [first.as_str(), copy.as_str(), "Second"]); assert_eq!((s.project.active_map, s.map().layers.len()), (1, layers)); assert_eq!(s.active_layer, s.map().layers.first().map(|l| l.id)); // The copy's own: no selection, and changing it leaves the original alone. assert_eq!(s.map().selection_count(), 0); assert!(s.project.maps[0].selection_count() > 0); s.map_mut().layers[0].name = "Changed".into(); s.map_mut().layers[0].dataset.features.clear(); assert_ne!(s.project.maps[0].layers[0].name, "Changed"); assert!(!s.project.maps[0].layers[0].dataset.features.is_empty()); // Layouts keep showing the original. let frames: Vec<&str> = s.project.layouts[0] .elements .iter() .filter_map(|e| match &e.kind { ElementKind::MapFrame { map, .. } => Some(map.as_str()), _ => None, }) .collect(); assert_eq!(frames, [first.as_str()]); // Copying again numbers the name; a given name is used; one undo removes a copy. assert_eq!(s.run("map.duplicate", &json!({"map": first})).unwrap()["name"], format!("{copy} 2")); assert_eq!(s.run("map.duplicate", &json!({"name": " Atlas "})).unwrap()["name"], "Atlas"); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.maps.len(), 4); for m in [json!(0), json!("Nowhere")] { assert!(s.run("map.duplicate", &json!({"map": m})).is_err(), "{m}"); } // A long name keeps to 200 characters with " copy". s.run("map.rename", &json!({"name": "é".repeat(300)})).unwrap(); let name = s.run("map.duplicate", &Value::Null).unwrap()["name"].as_str().unwrap_or_default().to_string(); assert_eq!((name.chars().count(), name.ends_with(" copy")), (200, true), "{name}"); } #[test] fn layout_duplicate_copies_every_element_and_opens_the_copy() { let mut s = world(); s.run("layout.new", &json!({"name": "Europe", "page": "a4"})).unwrap(); s.run("layout.new", &json!({"name": "Other"})).unwrap(); let r = s.run("layout.duplicate", &json!({"layout": "europe"})).unwrap(); assert_eq!((r["name"].as_str(), r["layout"].as_u64()), (Some("Europe copy"), Some(1))); let names: Vec<&str> = s.project.layouts.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, ["Europe", "Europe copy", "Other"]); assert_eq!(s.active_layout, Some(1)); let (a, b) = (&s.project.layouts[0], &s.project.layouts[1]); assert_eq!((&a.elements, a.width, a.height), (&b.elements, b.width, b.height)); s.project.layouts[1].elements.clear(); assert!(!s.project.layouts[0].elements.is_empty()); // The open layout by default, with a given name; one undo removes it. assert_eq!(s.run("layout.duplicate", &json!({"newName": "Print"})).unwrap()["name"], "Print"); assert_eq!(s.active_layout, Some(2)); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.layouts.len(), 3); // Undo takes the copy away, so the layout open before it opens again, not whatever sits at // the copy's old position; an open layout the undo keeps stays open (redo). let open = |s: &Session| s.project.layouts.get(s.active_layout.unwrap_or(usize::MAX)).map(|l| l.name.clone()); assert_eq!(open(&s).as_deref(), Some("Europe copy")); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!((open(&s).as_deref(), s.project.layouts[2].name.as_str()), (Some("Europe copy"), "Print")); s.run("edit.undo", &Value::Null).unwrap(); s.run("layout.activate", &json!({"name": "Other"})).unwrap(); s.run("layout.duplicate", &json!({"layout": "Europe"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(open(&s).as_deref(), Some("Other")); // Undoing a change made on the map leaves the layout opened since then open, and the map // view stays the map view. s.run("layout.close", &Value::Null).unwrap(); s.run("map.rename", &json!({"name": "Atlas"})).unwrap(); s.run("layout.activate", &json!({"name": "Europe"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(open(&s).as_deref(), Some("Europe")); s.run("layout.close", &Value::Null).unwrap(); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(s.active_layout, None); // A selector that is neither a name nor an index never falls back to the open layout. for l in [json!("Nowhere"), json!(false), json!(-1), json!(9), json!(["Europe"])] { assert!(s.run("layout.duplicate", &json!({"layout": l})).is_err(), "{l}"); } assert_eq!(s.project.layouts.len(), 3); assert!(s.run("layout.duplicate", &json!({"layout": 0.0})).is_ok(), "an index sent as a float (forms)"); } /// A command that fails part way (a task's later step) leaves open the layout that was open /// before it, not one an earlier step opened. #[test] fn a_failed_command_reopens_the_layout_open_before_it() { let mut s = world(); s.run("layout.new", &json!({"name": "A"})).unwrap(); s.run("layout.new", &json!({"name": "B"})).unwrap(); s.run("layout.activate", &json!({"name": "A"})).unwrap(); let r = s.guarded("Task", true, |s| { s.run("layout.activate", &json!({"name": "B"}))?; Err("a later step failed".into()) }); assert!(r.is_err()); assert_eq!(s.active_layout.and_then(|i| s.project.layouts.get(i)).map(|l| l.name.as_str()), Some("A")); } /// Text graphics can be bold (#516): added, styled or edited so; a graphics layer made before the /// field reads as not bold and gains the field when one is made bold. #[test] fn text_graphics_can_be_bold() { use rustgis_map::graphics; let mut s = world(); let a = s.run("graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "A", "bold": true})).unwrap(); let id = a["layer"].as_u64().unwrap() as u32; let b = s.run("graphics.add", &json!({"shape": "text", "points": [[20, 40]], "text": "B", "layer": id})).unwrap(); let (ra, rb) = (a["row"].as_u64().unwrap() as usize, b["row"].as_u64().unwrap() as usize); let ds = |s: &Session| s.map().layer(id).unwrap().dataset.clone(); assert!(graphics::bold(&ds(&s), ra) && !graphics::bold(&ds(&s), rb)); s.run("graphics.edit", &json!({"layer": id, "row": rb, "bold": true})).unwrap(); assert!(graphics::bold(&ds(&s), rb)); s.run("select.rows", &json!({"layer": id, "rows": [ra]})).unwrap(); s.run("graphics.style", &json!({"bold": false})).unwrap(); assert!(!graphics::bold(&ds(&s), ra)); // A layer from before the field: not bold, until made so. let l = s.map_mut().layer_mut(id).unwrap(); let fi = l.dataset.field_index("bold").unwrap(); l.dataset.fields.remove(fi); l.dataset.features.iter_mut().for_each(|f| { f.attrs.remove(fi); }); assert!(!graphics::bold(&ds(&s), rb)); s.run("graphics.edit", &json!({"layer": id, "row": rb, "bold": true})).unwrap(); assert!(graphics::bold(&ds(&s), rb) && !graphics::bold(&ds(&s), ra)); } /// Text graphics take italic, alignment and letter spacing (#535) as a layout's text does: added, /// styled or edited so, checked and limited the same; a layer made before the fields reads as /// plain text and gains each field when it is first set; one typed wrong in the attribute table /// reads as plain too. #[test] fn text_graphics_take_italic_alignment_and_letter_spacing() { use rustgis_map::graphics; use rustgis_map::layout::TextAlign; let mut s = world(); let a = s .run( "graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "A\nB", "italic": true, "align": "center", "letterSpacing": 3}), ) .unwrap(); let id = a["layer"].as_u64().unwrap() as u32; let b = s.run("graphics.add", &json!({"shape": "text", "points": [[20, 40]], "text": "B", "layer": id})).unwrap(); let (ra, rb) = (a["row"].as_u64().unwrap() as usize, b["row"].as_u64().unwrap() as usize); let ds = |s: &Session| s.map().layer(id).unwrap().dataset.clone(); let style = |s: &Session, r| (graphics::italic(&ds(s), r), graphics::align(&ds(s), r), graphics::letter_spacing(&ds(s), r)); assert_eq!(style(&s, ra), (true, TextAlign::Center, 3.0)); assert_eq!(style(&s, rb), (false, TextAlign::Left, 0.0), "not given: plain"); // Edited, styled and limited as a layout's text is. s.run("graphics.edit", &json!({"layer": id, "row": rb, "align": "Right", "letterSpacing": 900})).unwrap(); assert_eq!(style(&s, rb), (false, TextAlign::Right, 50.0)); s.run("graphics.edit", &json!({"layer": id, "row": rb, "letterSpacing": -40})).unwrap(); assert_eq!(style(&s, rb).2, -5.0); s.run("select.rows", &json!({"layer": id, "rows": [ra]})).unwrap(); s.run("graphics.style", &json!({"italic": false})).unwrap(); assert_eq!(style(&s, ra), (false, TextAlign::Center, 3.0), "only what is given changes"); // Wrong values are refused, and change nothing; the message names the parameter. for (bad, name) in [(json!({"align": "up"}), "alignment"), (json!({"italic": "maybe"}), "italic"), (json!({"letterSpacing": "wide"}), "letterSpacing")] { let mut p = bad.clone(); p["layer"] = json!(id); p["row"] = json!(ra); let err = s.run("graphics.edit", &p).unwrap_err(); assert!(err.contains(name), "{bad}: {err}"); let mut p = bad; p["shape"] = json!("text"); p["points"] = json!([[0, 0]]); p["text"] = json!("C"); assert!(s.run("graphics.add", &p).is_err()); } assert_eq!(style(&s, ra), (false, TextAlign::Center, 3.0)); assert!(s.run("graphics.style", &json!({})).unwrap_err().contains("letterSpacing"), "the style's own words list them"); // Placed again with a style: both happen; with a bad style, neither (the session puts back a // command that fails); with only nulls for a style, it is just placed. let at = |s: &Session| ds(s).features[ra].geometry.clone(); let before = at(&s); for bad in [json!({"align": "up"}), json!({"fill": "red"})] { let mut p = bad; p["layer"] = json!(id); p["row"] = json!(ra); p["points"] = json!([[11, 50]]); assert!(s.run("graphics.edit", &p).is_err()); assert_eq!(at(&s), before, "placed again though its style was refused"); } s.run("graphics.edit", &json!({"layer": id, "row": ra, "points": [[12, 50]], "italic": null, "fill": null})).unwrap(); let nulls = at(&s); assert!(nulls != before && !graphics::italic(&ds(&s), ra), "nulls are no style: placed, style kept"); s.run("graphics.edit", &json!({"layer": id, "row": ra, "points": [[11, 50]], "italic": true})).unwrap(); assert!(graphics::italic(&ds(&s), ra) && at(&s) != nulls); // A layer from before the fields: plain, until each is set. let l = s.map_mut().layer_mut(id).unwrap(); for name in ["italic", "align", "letter_spacing"] { let fi = l.dataset.field_index(name).unwrap(); l.dataset.fields.remove(fi); l.dataset.features.iter_mut().for_each(|f| { f.attrs.remove(fi); }); } assert_eq!(style(&s, ra), (false, TextAlign::Left, 0.0)); s.run("graphics.edit", &json!({"layer": id, "row": ra, "align": "center"})).unwrap(); assert_eq!(style(&s, ra), (false, TextAlign::Center, 0.0)); assert!(ds(&s).field_index("align").is_some() && ds(&s).field_index("italic").is_none(), "only the field set is gained"); // Typed wrong in the attribute table: read as plain, within range. let l = s.map_mut().layer_mut(id).unwrap(); let (fa, fs) = ( l.dataset.ensure_field("align", rustgis_data::FieldType::Text), l.dataset.ensure_field("letter_spacing", rustgis_data::FieldType::Double), ); l.dataset.features[ra].attrs[fa] = rustgis_data::Value::Text("sideways".into()); l.dataset.features[ra].attrs[fs] = rustgis_data::Value::Float(f64::NAN); assert_eq!(style(&s, ra), (false, TextAlign::Left, 0.0)); } /// Text graphics pasted into a graphics layer made before their style fields keep their style /// (#583): the layer gains the fields they carry, from the session's clipboard and from GeoJSON /// text alike; features that aren't graphics add none, even with a field of the same name. #[test] fn pasted_graphics_keep_their_style_in_an_older_graphics_layer() { use rustgis_map::graphics; use rustgis_map::layout::TextAlign; let mut s = world(); let a = s .run( "graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "Styled", "bold": true, "italic": true, "align": "right", "letterSpacing": 4}), ) .unwrap(); let (from, row) = (a["layer"].as_u64().unwrap() as u32, a["row"].as_u64().unwrap() as usize); // A graphics layer from before the style fields. let old = |s: &mut Session, name: &str| { let id = s.run("graphics.newLayer", &json!({"name": name})).unwrap()["layer"].as_u64().unwrap() as u32; let l = s.map_mut().layer_mut(id).unwrap(); for f in ["bold", "italic", "align", "letter_spacing"] { let fi = l.dataset.field_index(f).unwrap(); l.dataset.fields.remove(fi); l.dataset.features.iter_mut().for_each(|ft| { ft.attrs.remove(fi); }); } id }; let style = |s: &Session, id: u32| { let ds = &s.map().layer(id).unwrap().dataset; let r = ds.features.len() - 1; (graphics::bold(ds, r), graphics::italic(ds, r), graphics::align(ds, r), graphics::letter_spacing(ds, r)) }; s.run("select.rows", &json!({"layer": from, "rows": [row]})).unwrap(); let text = s.run("edit.copy", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); // From the session's clipboard (the Paste command) and from its GeoJSON (Ctrl+V in the app). for via_text in [false, true] { let to = old(&mut s, "Older"); let mut p = json!({"layer": to}); if via_text { p["text"] = json!(text); } s.run("edit.paste", &p).unwrap(); assert_eq!(style(&s, to), (true, true, TextAlign::Right, 4.0), "via text: {via_text}"); } // Features that aren't graphics add no field, though one is named as a style: with no `kind`, // or with a `kind` of their own that isn't a graphic's. for properties in [r#"{"align":"N"}"#, r#"{"kind":"inspection","align":"center"}"#] { let to = old(&mut s, "Notes"); let plain = format!( r#"{{"type":"FeatureCollection","features":[{{"type":"Feature","geometry":{{"type":"Point","coordinates":[1,2]}},"properties":{properties}}}]}}"# ); s.run("edit.paste", &json!({"layer": to, "text": plain})).unwrap(); assert!(s.map().layer(to).unwrap().dataset.field_index("align").is_none(), "{properties}"); } // Pasted together, a graphic with no alignment and another feature with one: the feature's // value doesn't make the layer gain the field. let to = old(&mut s, "Mixed"); let mixed = r#"{"type":"FeatureCollection","features":[ {"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{"kind":"text","text":"A"}}, {"type":"Feature","geometry":{"type":"Point","coordinates":[3,4]},"properties":{"kind":"inspection","align":"center"}}]}"#; s.run("edit.paste", &json!({"layer": to, "text": mixed})).unwrap(); assert!(s.map().layer(to).unwrap().dataset.field_index("align").is_none()); } /// A text graphic is placed again by its points (dragged on the map, #516), as one undo step; /// other shapes move with `edit.move`. #[test] fn a_text_graphic_is_placed_again_by_its_points() { let mut s = world(); let t = s.run("graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "A"})).unwrap(); let id = t["layer"].as_u64().unwrap() as u32; let row = t["row"].as_u64().unwrap() as usize; let at = |s: &Session| s.map().layer(id).unwrap().dataset.features[row].geometry.as_ref().unwrap().label_point().unwrap(); s.run("graphics.edit", &json!({"layer": id, "row": row, "points": [[12, 48]]})).unwrap(); assert_eq!((at(&s).x, at(&s).y), (12.0, 48.0)); assert_eq!(s.undo_label(), Some("Edit Graphic")); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!((at(&s).x, at(&s).y), (10.0, 50.0)); let r = s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 0], [5, 5]], "layer": id})).unwrap(); assert!(s.run("graphics.edit", &json!({"layer": id, "row": r["row"], "points": [[1, 1]]})).is_err()); } /// A point, a text graphic's too, turns and scales on itself: Rotate and Scale leave it out, and /// with nothing else selected they change nothing and leave no undo step (#516). #[test] fn rotating_or_scaling_a_text_changes_nothing() { let mut s = world(); let t = s.run("graphics.add", &json!({"shape": "text", "points": [[10, 50]], "text": "A"})).unwrap(); let layer = t["layer"].as_u64().unwrap(); s.run("select.rows", &json!({"layer": layer, "rows": [t["row"]]})).unwrap(); let label = s.undo_label(); assert!(s.run("edit.rotate", &json!({"angle": 34})).is_err()); assert!(s.run("edit.scale", &json!({"factor": 2})).is_err()); assert_eq!(s.undo_label(), label, "no undo step"); // With a shape beside it, the shape turns. let r = s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 0], [5, 5]], "layer": layer})).unwrap(); s.run("select.rows", &json!({"layer": layer, "rows": [t["row"], r["row"]]})).unwrap(); assert_eq!(s.run("edit.rotate", &json!({"angle": 34})).unwrap()["modified"], 1); } /// A command that opens another project part way and then fails (a plugin's command through its /// bridge) leaves the project before it open, counted as it was: the interface keeps what it /// holds for it. #[test] fn a_failed_command_keeps_the_project_open_before_it() { let mut s = world(); // Opened from a file, edited, unsaved, with a task made here. s.path = Some("atlas.rgis".into()); s.run("task.new", &json!({"name":"mine", "steps":[]})).unwrap(); s.run("map.rename", &json!({"name": "Edited"})).unwrap(); let (name, opened, undo) = (s.project.name.clone(), s.opened, s.undo_label()); assert!(undo.is_some() && s.dirty()); let r = s.guarded("Task", true, |s| { s.run("project.new", &json!({"name": "Other"}))?; Err("a later step failed".into()) }); assert!(r.is_err()); assert_eq!((s.project.name.as_str(), s.opened), (name.as_str(), opened)); // It comes back with what it held: its undo history, its unsaved state, the trust it had, and // its file, so Save writes there. assert_eq!(s.undo_label(), undo); assert!(s.dirty() && s.item_trusted("task", "mine")); assert_eq!(s.path.as_deref(), Some(std::path::Path::new("atlas.rgis"))); s.run("edit.undo", &Value::Null).unwrap(); assert_ne!(s.map().name, "Edited", "the edit before still undoes"); // A step failing after a replacement in its own command leaves the new project as it is; the // task failing gives the first one back. s.run("map.rename", &json!({"name": "Edited"})).unwrap(); let undo = s.undo_label(); let r = s.guarded("Task", true, |s| { s.run("project.new", &json!({"name": "Other"}))?; // A step that runs (no condition stops it first) and fails inside, rolled back on its own. let rev = s.revision; assert!(s.run("map.rename", &json!({"name": ""})).is_err()); assert!(s.revision > rev, "it ran as a command"); assert_eq!(s.project.name, "Other"); Err("a later step failed".into()) }); assert!(r.is_err()); assert_eq!((s.project.name.as_str(), s.undo_label()), (name.as_str(), undo)); // Its extent history comes back usable, though the project the task opened had another // projection, and moving its view took the history for that projection. s.record_extent(); s.map_mut().camera.center.x += 1e6; s.record_extent(); assert!(s.can_extent_back()); let r = s.guarded("Task", true, |s| { s.run("project.new", &json!({"name": "Other"}))?; s.run("map.projection", &json!({"type": "mollweide"}))?; s.record_extent(); Err("a later step failed".into()) }); assert!(r.is_err()); assert!(s.can_extent_back(), "the extent history is the restored map's"); // Opened for good, it counts, and the old project's history and file go with it. s.run("project.new", &json!({"name": "Other"})).unwrap(); assert_eq!((s.opened, s.undo_label(), s.path.as_deref()), (opened + 1, None, None)); assert!(!s.item_trusted("task", "mine")); } /// A run from the history as code (#539): a Python call, a Script pane line, a CLI command. A /// point buffered where the map was clicked goes by the command that ran it, with the point; keys /// are left out; a run from before the tool's id was kept is found by its name. #[test] fn a_run_is_copied_from_the_history_as_code() { let mut s = world(); s.run("gp.run", &json!({"tool": "buffer", "params": {"input": "Cities", "distance": 100}})).unwrap(); let r = &s.project.gp_history[0]; assert!((r.id.as_str(), r.at > 0) == ("buffer", true), "the id and when it ran are kept"); let code = |s: &mut Session, run: usize, kind: &str| -> Result { s.run("gp.historyCode", &json!({"run": run, "as": kind})).map(|v| v["code"].as_str().unwrap_or("").to_string()) }; let py = code(&mut s, 0, "python").unwrap(); assert!(py.starts_with("s.run(\n \"gp.run\",\n tool=\"buffer\",\n params={\n"), "{py}"); assert!(py.contains("\"distance\": 100,") && py.contains("\"input\": \"Cities\",") && py.ends_with(")"), "{py}"); assert_eq!( code(&mut s, 0, "command").unwrap(), r#"gp.run {"params":{"dissolve":"None","distance":100,"input":"Cities","units":"Kilometers"},"tool":"buffer"}"# ); let cli = code(&mut s, 0, "cli").unwrap(); assert!( cli.starts_with("rustgis-cli run --project '") && cli.ends_with( r#"--cmd 'gp.run={"params":{"dissolve":"None","distance":100,"input":"Cities","units":"Kilometers"},"tool":"buffer"}'"# ), "{cli}" ); // A point buffer: by its command, with the point. s.run("gp.bufferPoint", &json!({"lon": 10, "lat": 50, "distance": 5})).unwrap(); let py = code(&mut s, 1, "python").unwrap(); assert!(py.contains("\"gp.bufferPoint\"") && py.contains("lon=10") && py.contains("lat=50") && py.contains("distance=5"), "{py}"); // From an older project: no id, found by its name; its address without the key. let old = crate::GpRecord { tool: "Buffer".into(), id: String::new(), params: json!({"input": "https://h.example/a?token=s3cr3t", "distance": 1, "connection": "PG:host=db user=me password=hunter2 dbname=x", "https://k.example/?token=k3y": 1, "odbc": "DRIVER=x;UID=me;PWD=odbcpw", "libpq": "host=db user=me password=libpqpw dbname=x", "gdal": "MYSQL:db,user=me,password=gdalpw", "password": "pa55word", "api_token": "abc123", "lookup": {"key": "NAME"}, "where": "key=5 AND sig=2", "spaced": "host=db password = sp1 user=me password= sp2 port=1 password =sp3", "quoted": "user=me password='top secret' dbname=x", "braced": "UID=me;PWD={a;b c};X=1", "pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpemsecret\n-----END RSA PRIVATE KEY-----\nafter", "service": {"url": "https://api.example.com", "key": "shortk"}, "escaped": "password='esc1\\' esc2' dbname=y"}), command: None, at: 0, ok: false, messages: vec!["no layer https://h.example/a?token=s3cr3t".into()], millis: 0, outputs: Vec::new(), }; s.project.gp_history.push(old.clone()); // Open in Tool finds the tool of a `gp.run` run, an older one's by its name; not a point // buffer's, which another command made. use crate::cmd::history::tool_of; let runs = &s.project.gp_history; assert_eq!((tool_of(&runs[0]).as_deref(), tool_of(&runs[1]), tool_of(&runs[2]).as_deref()), (Some("buffer"), None, Some("buffer"))); let py = code(&mut s, 2, "python").unwrap(); assert!(py.contains("tool=\"buffer\"") && py.contains("https://h.example/a") && !py.contains("s3cr3t"), "{py}"); // Replay keeps opaque text and unknown nested content, with an honest warning. The log's // separate, stricter projection still exercises the legacy connection/text cases below. assert!(py.contains("odbcpw") && py.contains("libpqpw") && py.contains("shortk"), "{py}"); assert!(s.notices.iter().any(|n| n.text.contains("opaque parameters"))); let py = serde_json::to_string_pretty(&crate::cmd::history::redacted(&old.params)).unwrap(); // A libpq connection keeps its words but its password, though spaces part them. assert!(py.contains("host=db") && !py.contains("hunter2"), "{py}"); // A connection with no prefix `redact` knows keeps its words but its secret-named values. for pw in ["odbcpw", "libpqpw", "gdalpw"] { assert!(!py.contains(pw), "{pw} in {py}"); } assert!( py.contains("UID=me;PWD=\"") && py.contains("host=db user=me password= dbname=x") && py.contains("user=me,password=\""), "{py}" ); // A quoted or braced value goes whole; a private key pasted in goes; a plain `key` beside // an address is that service's key. // An escaped quote (libpq's `\'`) doesn't close a quoted value. for secret in ["top", "secret'", "b c", "pemsecret", "shortk", "esc1", "esc2"] { assert!(!py.contains(secret), "{secret} in {py}"); } assert!( py.contains("password= dbname=x") && py.contains("PWD=;X=1") && py.contains("after") && py.contains("password= dbname=y"), "{py}" ); // A bare `key` in an expression is a field, kept. assert!(py.contains("key=5 AND sig=2"), "{py}"); // libpq's spaces around the `=` don't let the value through. assert!(!py.contains("sp1") && !py.contains("sp2") && !py.contains("sp3") && py.contains("user=me password= port=1"), "{py}"); // A key is redacted as a value is. assert!(py.contains("https://k.example/") && !py.contains("k3y"), "{py}"); // A value under a secret's name is left empty; a plain `key` that reads as a name is kept. assert!(!py.contains("pa55word") && !py.contains("abc123") && py.contains("\"password\": \"\"") && py.contains("\"NAME\""), "{py}"); // Nothing tells which tool an unknown name was. s.project.gp_history.push(crate::GpRecord { tool: "Mystery".into(), ..old.clone() }); assert!(code(&mut s, 3, "python").is_err() && code(&mut s, 9, "python").is_err()); // A project file passing another command off as a run: not written into code. let forged = crate::GpRecord { command: Some(("file.delete\nproject.new".into(), json!({}))), ..old }; s.project.gp_history.push(forged.clone()); assert!(code(&mut s, 4, "command").is_err()); assert_eq!(crate::cmd::history::tool_of(&forged), None); // The whole history: the project opened, each run in order; a failed run commented out, as // is one that can't be run again. let out = s.run("gp.historyScript", &Value::Null).unwrap(); let text = out["text"].as_str().unwrap(); assert!(text.contains("import rustgis\n\ns = rustgis.Session(") && text.contains("# 1. Buffer ("), "{text}"); assert!(text.contains("UTC)\ns.run(\n \"gp.run\""), "{text}"); assert_eq!( text.lines().filter(|line| line.starts_with("s.run(")).count(), 2, "failed or unrecognized runs must not become executable Python: {text}" ); assert!(text.contains("# 4. Mystery: Mystery can't be run again"), "{text}"); assert!(text.contains("# 5. Buffer: Buffer can't be run again") && !text.contains("file.delete"), "{text}"); // Text from the file stays in its comment, whatever line break it holds (a lone `\r` ends a // line for Python). let sneaky = crate::GpRecord { tool: "x\rimport os".into(), id: "buffer".into(), ok: false, messages: vec!["boom\u{2028}import sys\nimport os".into()], ..forged }; s.project.gp_history.push(crate::GpRecord { command: None, ..sneaky }); let text = s.run("gp.historyScript", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); assert!(!text.contains(['\r', '\u{2028}']), "{text:?}"); assert!(text.lines().filter(|l| l.contains("import os") || l.contains("import sys")).all(|l| l.starts_with('#')), "{text}"); assert!(!text.contains("s3cr3t") && !text.contains("hunter2")); assert!(text.contains("# The project hasn't been saved yet: save it as this file first.\nimport rustgis"), "{text}"); // Named as Save names it, spaces and all. assert!(text.contains("\"World Atlas.rgis\""), "{text}"); // Saved, then changed: the script says to save it again first. s.path = Some("atlas.rgis".into()); s.mark_unsaved(); let text = s.run("gp.historyScript", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); assert!( text.contains("# The project has changes that aren't saved: save it first") && !text.contains("hasn't been saved yet"), "{text}" ); s.mark_saved(); let text = s.run("gp.historyScript", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); assert!(!text.contains("aren't saved") && text.contains("rustgis.Session(\"atlas.rgis\")"), "{text}"); s.path = None; // A file name every system takes, whatever the project is called. s.project.name = "A*B?\"C|E".into(); let out = s.run("gp.historyScript", &Value::Null).unwrap(); assert_eq!(out["fileName"], "A_B__C_D__E_history.py"); s.project.gp_history.clear(); assert!(s.run("gp.historyScript", &Value::Null).is_err(), "nothing to save"); } /// A secret in a query string joined by `&`, or in a failed run's error quoting a connection, /// is left out of the copied code and the history script; so is one with an `&` in it, or an /// ODBC braced one with an escaped `}}`. #[test] fn secrets_in_a_query_or_a_run_s_error_stay_out_of_copied_code() { let mut s = world(); s.project.gp_history.push(crate::GpRecord { tool: "Buffer".into(), id: "buffer".into(), params: json!({"input": "Cities", "query": "a=1&password=qp&b=2", "amp": "password=pw1&zz9", "quoted": "password='q1&b=c2'", "odbc": "PWD={abc}}def;tail};UID=me", "apostrophe": "a=O'Reilly&password=ap1&b=2", "stray": "a='x&password=ap2"}), command: None, at: 0, ok: false, messages: vec!["can't connect: host=db password=mp a=1&token=mp3".into()], millis: 0, outputs: Vec::new(), }); let py = s.run("gp.historyCode", &json!({"run": 0})).unwrap()["code"].as_str().unwrap().to_string(); assert!(py.contains("password=qp"), "opaque query text stays replayable: {py}"); assert!(s.notices.iter().any(|n| n.text.contains("opaque parameters"))); let py = serde_json::to_string_pretty(&crate::cmd::history::redacted(&s.project.gp_history[0].params)).unwrap(); assert!(py.contains("a=1&password=&b=2") && !py.contains("qp"), "{py}"); // An `&` in a value, or in quotes, isn't another pair: the whole value goes. So does an ODBC // braced value with an escaped `}}` in it. for secret in ["pw1", "zz9", "q1", "c2", "abc", "def", "tail"] { assert!(!py.contains(secret), "{secret} in {py}"); } assert!(py.contains("\"PWD=;UID=me\""), "{py}"); // A quote inside a word opens nothing; one that opens a value and never closes doesn't hide // the pairs after it. assert!(!py.contains("ap1") && !py.contains("ap2"), "{py}"); assert!(py.contains("a=O'Reilly&password=&b=2") && py.contains("a='x&password="), "{py}"); let text = s.run("gp.historyScript", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); assert!( text.contains("failed when it ran (can't connect: host=db password= a=1&token=)") && !text.contains("password=mp") && !text.contains("mp3"), "{text}" ); } #[test] fn history_exports_never_keep_unknown_url_credential_continuations() { let mut s = world(); let raw = "GET https://history.example/data?token=hidden hidden-tail: HTTP 503"; s.project.gp_history.push(crate::GpRecord { tool: "Buffer".into(), id: "buffer".into(), params: json!({ "input":"Cities", "note":raw, "url":"https://history.example/data?page=2&token=hidden", "markup":"hidden hidden-tail https://history.example/data", "payload":r#"{"password":"hidden hidden-tail","link":"https://history.example/data"}"#, "compact":r#"{"password":"hidden"}"#, "markupWithoutUrl":"hidden", }), command: None, at: 0, ok: false, messages: vec![raw.into()], millis: 0, outputs: Vec::new(), }); // Upstream copied code keeps opaque step text replayable and says so; typed addresses and the // failed run's diagnostic still lose their credentials. let code = s.run("gp.historyCode", &json!({"run":0})).unwrap()["code"].as_str().unwrap().to_string(); assert!(code.contains("https://history.example/data?page=2") && !code.contains("page=2&token"), "{code}"); assert!(s.notices.iter().any(|n| n.text.contains("opaque parameters"))); let script = s.run("gp.historyScript", &Value::Null).unwrap()["text"].as_str().unwrap().to_string(); let failure = script.lines().find(|l| l.contains("failed when it ran")).unwrap_or_default(); assert!(failure.contains("GET https://history.example/data") && !failure.contains("hidden"), "{script}"); // The parameter sanitizer used for review/redacted output removes every structured secret. let clean = serde_json::to_string(&crate::cmd::history::redacted(&s.project.gp_history[0].params)).unwrap(); assert!(!clean.contains("hidden") && clean.contains("https://history.example/data?page=2"), "{clean}"); } #[test] fn copied_history_replays_public_structured_parameters_without_changing_results() { let mut s = Session::new(Project::default()); s.run( "layer.addDataText", &json!({ "fileName":"Words.geojson", "text":r#"{"type":"FeatureCollection","features":[ {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"word":"A"}}, {"type":"Feature","geometry":{"type":"Point","coordinates":[1,1]},"properties":{"word":"B"}} ]}"#, }), ) .unwrap(); for separator in ["
https://example.org
", r#"{"label":"public","link":"https://example.org?format=json"}"#] { s.run( "gp.run", &json!({ "tool":"summaryStatistics", "params":{"input":"Words","statistics":[{"field":"word","statistic":"CONCATENATE"}],"separator":separator} }), ) .unwrap(); let run = s.project.gp_history.len() - 1; let copied = s.run("gp.historyCode", &json!({"run":run,"as":"command"})).unwrap(); let (command, params) = copied["code"].as_str().unwrap().split_once(' ').unwrap(); let params: Value = serde_json::from_str(params).unwrap(); s.run(command, ¶ms).unwrap(); let replayed = s.project.tables.last().unwrap(); assert_eq!( replayed.value(0, replayed.field_index("CONCATENATE_word").unwrap()), &rustgis_data::Value::Text(format!("A{separator}B")) ); } } #[path = "../../../tests/support/ogc_server.rs"] mod ogc_server; #[path = "../../../tests/support/esripbf.rs"] mod esripbf_answers; #[test] fn ogc_landing_fixture_lists_and_loads_features() { use ogc_server::{CATALOG, ITEMS, ROOT, Server}; let server = Server::fixture(); let mut session = Session::new(Project::default()); let listing = session.run("service.layers", &json!({"url": server.root()})).unwrap(); assert_eq!(listing["count"], 1); assert_eq!(listing["layers"][0]["name"], "places"); assert_eq!(listing["layers"][0]["source_url"], format!("{}{CATALOG}?f=json&key=fixture-key", server.base)); assert_eq!(listing["layers"][0]["offer"]["url"], format!("{}{ITEMS}?key=fixture-key&limit=10000", server.base)); assert!(listing.get("next").is_none()); let result = session.run("service.add", &json!({"url": server.root(), "layer": "places"})).unwrap(); assert_eq!(result["features"], 1); let dataset = &session.map().layer_by_name("Places").unwrap().dataset; assert_eq!(dataset.features.len(), 1); assert_eq!(dataset.features[0].geometry, Some(rustgis_data::Geometry::Point(rustgis_data::Coord::new(10., 50.)))); let name = dataset.fields.iter().position(|f| f.name == "name").unwrap(); assert_eq!(dataset.features[0].attrs[name], rustgis_data::Value::Text("Test Place".into())); server.assert_fixture_requests(&[ROOT, CATALOG, ROOT, CATALOG, ITEMS]); } #[test] fn ogc_landing_supplied_documents_never_fetch_capabilities() { use ogc_server::{CATALOG, Server, fixture_collections, fixture_landing}; let server = Server::fixture(); let mut session = Session::new(Project::default()); let mut params = json!({"url":server.root(), "text":fixture_landing()}); let initial = session.run("service.layers", ¶ms).unwrap(); assert_eq!(initial["count"], 0); let next = initial["next"].as_str().unwrap(); assert_eq!(next, format!("{}{CATALOG}?f=json&key=fixture-key", server.base)); params["documents"] = json!([{"url":next,"text":fixture_collections()}]); let complete = session.run("service.layers", ¶ms).unwrap(); assert_eq!(complete["count"], 1); assert!(complete.get("next").is_none()); assert!(server.requests().is_empty()); params["documents"][0]["text"] = json!("{\"collections\":[]}"); let empty = session.run("service.layers", ¶ms).unwrap(); assert_eq!(empty["count"], 0); assert!(empty.get("next").is_none()); } #[test] fn ogc_landing_keeps_map_tiles_and_collection_provenance() { use ogc_server::{CATALOG, ITEMS, Route, Server, fixture_collections, fixture_features, fixture_landing}; let mut landing: Value = serde_json::from_str(fixture_landing()).unwrap(); landing["links"].as_array_mut().unwrap().push(json!({ "rel":"tilesets-map", "type":"application/json", "href":"/map/tiles" })); let mut collections: Value = serde_json::from_str(fixture_collections()).unwrap(); // The dataset map and a feature collection can share the id "map". collections["collections"][0]["id"] = json!("map"); let server = Server::new(vec![Route::new(ITEMS, fixture_features())]); let root = format!("{}?f=json", server.root()); let catalog = format!("{}{CATALOG}?f=json&key=fixture-key", server.base); let mut params = json!({"url":server.root(),"text":landing.to_string(),"documents":[{"url":catalog,"text":collections.to_string()}]}); let mut session = Session::new(Project::default()); let listing = session.run("service.layers", ¶ms).unwrap(); assert_eq!(listing["count"], 2); assert_eq!(listing["kind"], "OGC API Features and Tiles"); assert_eq!(listing["layers"][0]["source_url"], root); assert_eq!(listing["layers"][1]["source_url"], catalog); params["layer"] = json!("map"); params["source_url"] = json!(root); params["tiles"] = json!(format!("{}/map/tiles/{{z}}/{{x}}/{{y}}.png", server.base)); session.run("service.add", ¶ms).unwrap(); assert_eq!(session.map().tiles.len(), 1); assert!(session.map().tiles[0].url.contains("/map/tiles/")); params["source_url"] = json!(catalog); session.run("service.add", ¶ms).unwrap(); assert_eq!(session.map().layer_by_name("Places").unwrap().dataset.features.len(), 1); server.assert_fixture_requests(&[ITEMS]); // A bad collections answer must not discard the landing page's usable map. params["documents"][0]["text"] = json!("not JSON"); let partial = session.run("service.layers", ¶ms).unwrap(); assert_eq!(partial["count"], 1); assert!(partial.get("next").is_none()); assert!(!partial["notes"].as_array().unwrap().is_empty()); } #[test] fn ogc_landing_discovery_is_bounded_and_checks_addresses() { let mut session = Session::new(Project::default()); let link = |url: &str| json!({"links":[{"rel":"data","type":"application/json","href":url}]}).to_string(); let root = "https://example.org/ogc/root"; for target in ["http://127.0.0.1/catalog", "http://169.254.169.254/catalog", "file:///etc/passwd"] { let result = session.run("service.layers", &json!({"url":root,"text":link(target)})); assert!(result.is_err(), "{target}"); } let error = session.run("service.layers", &json!({"url":root,"text":link(&format!("{root}?f=json"))})).unwrap_err(); assert!(error.contains("loop"), "{error}"); let docs: Vec = (1..=3) .map(|n| { json!({ "url":format!("https://example.org/ogc/{n}"), "text":link(&format!("https://example.org/ogc/{}",n+1)) }) }) .collect(); let error = session.run("service.layers", &json!({"url":root,"text":link("https://example.org/ogc/1"),"documents":docs})).unwrap_err(); assert!(error.contains("four documents"), "{error}"); let docs = json!([ {"url":"https://example.org/ogc/a","text":link("https://example.org/ogc/b")}, {"url":"https://example.org/ogc/b","text":link("https://example.org/ogc/a")} ]); assert!( session .run("service.layers", &json!({"url":root,"text":link("https://example.org/ogc/a"),"documents":docs})) .unwrap_err() .contains("loop") ); for documents in [json!({}), json!([{}]), json!([{"url":root,"text":23}]), json!([{}, {}, {}, {}])] { assert!(session.run("service.layers", &json!({"url":root,"text":ogc_server::fixture_landing(),"documents":documents})).is_err()); } for text in ["{", "{}", "{\"links\":[]}"] { assert!(session.run("service.layers", &json!({"url":root,"text":text})).is_err()); } } #[test] fn ogc_landing_http_failure_keeps_existing_map_layers() { use ogc_server::{CATALOG, ROOT, Route, Server, fixture_landing}; for with_map in [false, true] { let mut landing: Value = serde_json::from_str(fixture_landing()).unwrap(); if with_map { landing["links"].as_array_mut().unwrap().push(json!({"rel":"tilesets-map","href":"/map/tiles"})); } let mut bad = Route::new(CATALOG, "unavailable"); bad.status = 503; let server = Server::new(vec![Route::new(ROOT, &landing.to_string()), bad]); let mut session = Session::new(Project::default()); let result = session.run("service.layers", &json!({"url":server.root()})); if with_map { let result = result.unwrap(); assert_eq!(result["count"], 1); assert!(result.get("next").is_none()); assert!(result["notes"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains("503"))); } else { assert!(result.unwrap_err().contains("503")); } server.assert_fixture_requests(&[ROOT, CATALOG]); } } #[test] fn ogc_discovery_preserves_all_offers_within_the_collection_limit() { let collections: Vec = (0..1001) .map(|i| { json!({ "id": format!("collection-{i}"), "links": [ {"rel":"tilesets-map", "href":format!("/tiles/{i}")}, {"rel":"items", "type":"application/geo+json", "href":format!("/items/{i}")} ] }) }) .collect(); let mut session = Session::new(Project::default()); let listing = session .run("service.layers", &json!({"url":"https://example.org/collections","text":json!({"collections":collections}).to_string()})) .unwrap(); assert_eq!(listing["count"], 2002); assert_eq!(listing["layers"][2001]["name"], "collection-1000"); assert_eq!(listing["layers"][2001]["offer"]["type"], "features"); } #[test] fn ogc_discovery_keeps_features_whose_id_matches_a_generated_tile_name() { let doc = json!({"collections":[ {"id":"roads", "links":[{"rel":"tilesets-map","href":"/tiles/roads"}]}, {"id":"roads (map tiles)", "links":[{"rel":"items","type":"application/geo+json","href":"/items/other"}]} ]}); let mut session = Session::new(Project::default()); let listing = session.run("service.layers", &json!({"url":"https://example.org/collections","text":doc.to_string()})).unwrap(); assert_eq!(listing["count"], 2); assert_eq!(listing["layers"][0]["name"], listing["layers"][1]["name"]); assert_eq!(listing["layers"][0]["offer"]["type"], "tiles"); assert_eq!(listing["layers"][1]["offer"]["type"], "features"); assert_eq!(listing["layers"][1]["offer"]["url"], "https://example.org/items/other?limit=10000"); } #[test] fn ogc_group_import_refresh_and_persistence() { use ogc_server::{CATALOG, ITEMS, LINE_ITEMS, ROOT, Server, fixture_features}; let server = Server::group_fixture(); let mut s = Session::new(Project::default()); let added = s.run("service.import", &json!({"url":server.root()})).unwrap(); let group = added["group"].as_u64().unwrap() as u32; assert_eq!(s.map().service_groups.len(), 1); assert_eq!(s.map().service_groups[0].name, "My OGC Layer"); assert_eq!(s.map().service_groups[0].children.len(), 2); assert_eq!(s.map().layers.len(), 2); let points = s.map().layer_by_name("Places").unwrap().id; let lines = s.map().layer_by_name("Routes").unwrap().id; assert_eq!(s.map().layer(lines).unwrap().geometry_type(), rustgis_data::GeometryType::Polyline); server.assert_fixture_requests(&[ROOT, CATALOG, ITEMS, LINE_ITEMS]); // One operation, one undo entry, including the parent and all children. s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layers.is_empty()); assert!(s.map().service_groups.is_empty()); s.run("edit.redo", &Value::Null).unwrap(); s.run("layer.rename", &json!({"layer":points,"name":"My places"})).unwrap(); s.run("layer.color", &json!({"layer":points,"color":"#123456"})).unwrap(); s.run("layer.visibility", &json!({"layer":points,"visible":false})).unwrap(); let style = serde_json::to_value(&s.map().layer(points).unwrap().renderer).unwrap(); let old_lines = serde_json::to_value(&s.map().layer(lines).unwrap().dataset).unwrap(); let project = serde_json::to_string(&s.project).unwrap(); s = Session::new(Project::from_json(&project).unwrap()); assert_eq!(s.map().service_groups[0].id, group); server.set(ITEMS, &fixture_features().replace("Test Place", "Updated Place")); let before = server.requests().len(); s.run("service.refresh", &json!({"layer":points})).unwrap(); let point = s.map().layer(points).unwrap(); assert_eq!(point.name, "My places"); assert!(!point.visible); assert_eq!(serde_json::to_value(&point.renderer).unwrap(), style); assert!(serde_json::to_string(&point.dataset).unwrap().contains("Updated Place")); assert_eq!(serde_json::to_value(&s.map().layer(lines).unwrap().dataset).unwrap(), old_lines); assert_eq!(server.requests()[before..].iter().map(|r| r.path.as_str()).collect::>(), [ROOT, CATALOG, ITEMS]); let before = server.requests().len(); s.run("service.refresh", &json!({"group":group})).unwrap(); assert_eq!(server.requests()[before..].iter().map(|r| r.path.as_str()).collect::>(), [ROOT, CATALOG, ITEMS, LINE_ITEMS]); assert_eq!(s.map().service_groups[0].children.iter().map(|c| c.layer).collect::>(), [points, lines]); assert!(!s.map().layer(points).unwrap().visible); } /// Importing a service group frames the first one on an empty map and leaves the view where it is /// when the map already shows something. #[test] fn importing_a_service_group_keeps_the_view() { use ogc_server::Server; let server = Server::group_fixture(); // A new map frames the group imported first: its point at 10° E, 50° N and the line from it. let mut e = Session::new(Project::default()); e.run("service.import", &json!({"url": server.root()})).unwrap(); let at = e.map().center_lonlat(); assert!((at.x - 10.5).abs() < 0.1 && (at.y - 50.5).abs() < 0.5, "{at:?}"); let mut s = Session::new(Project::default()); let point = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [0, 0]}}]}"#; s.run("layer.addDataText", &json!({"fileName": "origin.geojson", "text": point})).unwrap(); s.run("map.zoomToExtent", &json!({"extent": [0.0, 0.0, 10.0, 10.0]})).unwrap(); let before = s.map().camera; s.run("service.import", &json!({"url": server.root()})).unwrap(); assert_eq!(s.map().camera, before); // Removing its layers leaves the map empty even though the service group keeps their stale // entries: the next layer added is framed. let mut g = Session::new(Project::default()); g.run("service.import", &json!({"url": server.root()})).unwrap(); let ids: Vec = g.map().service_groups[0].children.iter().map(|c| c.layer).collect(); assert_eq!(ids.len(), 2); g.run("layer.remove", &json!({"layer": ids})).unwrap(); assert!(g.map().layers.is_empty()); assert!(!g.map().service_groups[0].children.is_empty(), "the service group keeps its entries"); let far = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}}]}"#; g.run("layer.addDataText", &json!({"fileName": "far.geojson", "text": far})).unwrap(); let at = g.map().center_lonlat(); assert!((at.x - 151.2).abs() < 0.1 && (at.y + 33.9).abs() < 0.5, "{at:?}"); } #[test] fn ogc_group_failures_are_atomic_and_membership_refreshes() { use ogc_server::{CATALOG, ITEMS, LINE_ITEMS, Server, fixture_collections, fixture_group_collections, fixture_lines}; let server = Server::group_fixture(); let mut s = Session::new(Project::default()); server.set(LINE_ITEMS, "not GeoJSON"); assert!(s.run("service.import", &json!({"url":server.root()})).is_err()); assert!(s.map().layers.is_empty()); assert!(s.map().service_groups.is_empty()); server.set(LINE_ITEMS, fixture_lines()); let group = s.run("service.import", &json!({"url":server.root()})).unwrap()["group"].clone(); let old = serde_json::to_value(&s.project).unwrap(); server.set(ITEMS, r#"{"type":"FeatureCollection","features":[]}"#); server.set(LINE_ITEMS, "not GeoJSON"); assert!(s.run("service.refresh", &json!({"group":group})).is_err()); assert_eq!(serde_json::to_value(&s.project).unwrap(), old); // A failed linked document must not masquerade as an empty collection list. server.set(CATALOG, "not JSON"); assert!(s.run("service.refresh", &json!({"group":group})).is_err()); assert_eq!(serde_json::to_value(&s.project).unwrap(), old); server.set(CATALOG, fixture_collections()); s.run("service.refresh", &json!({"group":group})).unwrap(); assert_eq!(s.map().layers.len(), 1); assert_eq!(s.map().service_groups[0].children.len(), 1); assert!(s.map().layers[0].dataset.features.is_empty()); s.run("edit.undo", &Value::Null).unwrap(); // Undo deliberately invalidates geometry caches; compare all persisted state except that revision. let mut restored = serde_json::to_value(&s.project).unwrap(); for layer in restored["maps"][0]["layers"].as_array_mut().unwrap() { layer["geometry_rev"] = json!(0); } assert_eq!(restored, old); server.set(CATALOG, fixture_group_collections()); server.set(LINE_ITEMS, fixture_lines()); s.run("service.refresh", &json!({"group":group})).unwrap(); let point = s.map().layer_by_name("Places").unwrap().id; s.run("layer.remove", &json!({"layer":point})).unwrap(); s.run("service.refresh", &json!({"group":group})).unwrap(); assert_eq!(s.map().layers.len(), 2); assert!(s.map().layer_by_name("Places").is_some()); s.run("service.group", &json!({"group":group,"visible":false,"expanded":false,"name":"Survey"})).unwrap(); assert!(s.map().layers.iter().all(|l| !l.visible)); assert_eq!(s.map().service_groups[0].name, "Survey"); assert!(!s.map().service_groups[0].expanded); // A newly advertised collection joins the group; a hidden group stays hidden. let mut more: Value = serde_json::from_str(fixture_group_collections()).unwrap(); more["collections"].as_array_mut().unwrap().push(json!({"id":"new-routes","title":"New Routes", "links":[{"rel":"items","type":"application/geo+json","href":"/new-routes"}]})); server.set("/new-routes", fixture_lines()); server.set(CATALOG, &more.to_string()); s.run("service.refresh", &json!({"group":group})).unwrap(); assert_eq!(s.map().service_groups[0].children.len(), 3); assert_eq!(s.map().layers.len(), 3); assert!(s.map().layers.iter().all(|l| !l.visible)); assert!(s.map().layer_by_name("New Routes").is_some()); s.run("service.group", &json!({"group":group,"remove":true})).unwrap(); assert!(s.map().layers.is_empty()); assert!(s.map().service_groups.is_empty()); } #[test] fn ogc_group_supplied_responses_never_fetch_and_need_every_child() { use ogc_server::{CATALOG, ROOT, fixture_features, fixture_group_collections, fixture_landing, fixture_lines}; let mut s = Session::new(Project::default()); let mut p = json!({"url":format!("https://example.org{ROOT}"), "text":fixture_landing(), "documents":[{"url":format!("https://example.org{CATALOG}?f=json&key=fixture-key"),"text":fixture_group_collections()}]}); let plan = s.run("service.groupPlan", &p).unwrap(); let points = plan["layers"][0]["offer"]["url"].clone(); let lines = plan["layers"][1]["offer"]["url"].clone(); p["responses"] = json!([{"url":points,"text":fixture_features()}]); assert!(s.run("service.import", &p).unwrap_err().contains("missing downloaded response")); assert!(s.map().layers.is_empty()); p["responses"] = json!([{"url":points,"text":fixture_features()},{"url":lines,"text":fixture_lines()}]); s.run("service.import", &p).unwrap(); assert_eq!(s.map().layers.len(), 2); assert_eq!(s.map().service_groups[0].children.len(), 2); for id in ["service.import", "service.refresh", "service.groupPlan"] { assert!(crate::cmd::tiles::NETWORK.contains(&id)); } } #[test] fn ogc_group_refresh_keeps_children_across_rotated_keys_and_rejects_bad_answers() { use ogc_server::{CATALOG, ROOT, fixture_features, fixture_group_collections, fixture_landing, fixture_lines}; let doc = |key: &str| { json!({"url":format!("https://example.org{ROOT}"), "text":fixture_landing().replace("fixture-key", key), "documents":[{"url":format!("https://example.org{CATALOG}?f=json&key={key}"), "text":fixture_group_collections().replace("fixture-key", key)}]}) }; let answers = |s: &mut Session, p: &Value, points: &str, lines: &str| { let plan = s.run("service.groupPlan", p).unwrap(); json!([{"url":plan["layers"][0]["offer"]["url"],"text":points},{"url":plan["layers"][1]["offer"]["url"],"text":lines}]) }; let mut s = Session::new(Project::default()); let mut p = doc("fixture-key"); p["responses"] = answers(&mut s, &p, fixture_features(), fixture_lines()); let added = s.run("service.import", &p).unwrap(); let group = added["group"].clone(); let ids: Vec = s.map().service_groups[0].children.iter().map(|c| c.layer).collect(); s.run("layer.rename", &json!({"layer":ids[0], "name":"Renamed"})).unwrap(); s.map_mut().label_lock = Some(vec![((ids[0], 0, 0, 0), Default::default()), ((ids[1], 0, 0, 0), Default::default())]); // A rotated key changes every source address but not the collections: the children stay. let mut q = doc("rotated-key"); q["responses"] = answers(&mut s, &q, fixture_features(), fixture_lines()); q["group"] = group.clone(); s.run("service.refresh", &q).unwrap(); let g = &s.map().service_groups[0]; assert_eq!(g.children.iter().map(|c| c.layer).collect::>(), ids); assert!(g.children.iter().all(|c| c.source_url.contains("rotated-key")), "{:?}", g.children); assert_eq!(s.map().layers.len(), 2); assert_eq!(s.map().layer(ids[0]).unwrap().name, "Renamed"); assert!(s.map().label_lock.as_ref().is_some_and(|l| l.is_empty())); // A child refresh follows a second rotation and records its new source. let mut c = doc("third-key"); c["responses"] = answers(&mut s, &c, fixture_features(), fixture_lines()); c["layer"] = json!(ids[1]); s.run("service.refresh", &c).unwrap(); let g = &s.map().service_groups[0]; assert!(g.children[1].source_url.contains("third-key") && g.children[0].source_url.contains("rotated-key")); assert_eq!(s.map().layers.len(), 2); // A FeatureCollection without a features array is malformed, not empty; `[]` is empty. let before = s.map().layer(ids[0]).unwrap().dataset.features.len(); for bad in [r#"{"type":"FeatureCollection"}"#, r#"{"type":"FeatureCollection","features":null}"#] { let mut r = doc("rotated-key"); r["responses"] = answers(&mut s, &r, bad, fixture_lines()); r["group"] = group.clone(); assert!(s.run("service.refresh", &r).unwrap_err().contains("FeatureCollection")); assert_eq!(s.map().layer(ids[0]).unwrap().dataset.features.len(), before); } let mut r = doc("rotated-key"); r["responses"] = answers(&mut s, &r, r#"{"type":"FeatureCollection","features":[]}"#, fixture_lines()); r["group"] = group.clone(); s.run("service.refresh", &r).unwrap(); assert_eq!(s.map().layer(ids[0]).unwrap().dataset.features.len(), 0); // A collections document past the listing limit can't be imported or refresh the group. let many: Vec = (0..2001) .map(|i| json!({"id":format!("c{i}"),"links":[{"rel":"items","type":"application/geo+json","href":format!("collections/c{i}/items")}]})) .collect(); let mut t = doc("fixture-key"); t["documents"][0]["text"] = json!(json!({"collections":many}).to_string()); assert!(s.run("service.groupPlan", &t).unwrap_err().contains("more than 2000")); t["group"] = group; assert!(s.run("service.refresh", &t).unwrap_err().contains("more than 2000")); assert_eq!(s.map().layers.len(), 2); } #[test] fn ogc_refresh_reconciles_fields_and_preserves_compatible_settings() { use ogc_server::{ITEMS, Server, fixture_features}; for renderer in ["unique", "graduated", "proportional"] { for refresh_child in [false, true] { let server = Server::group_fixture(); let features = |properties: Value| { let mut doc: Value = serde_json::from_str(fixture_features()).unwrap(); doc["features"][0]["properties"] = properties; doc.to_string() }; server.set(ITEMS, &features(json!({"name":"Place", "value":12, "keep":1}))); let mut s = Session::new(Project::default()); let group = s.run("service.import", &json!({"url":server.root()})).unwrap()["group"].clone(); let layer = s.map().layer_by_name("Places").unwrap().id; let target = if refresh_child { json!({"layer":layer}) } else { json!({"group":group}) }; s.run("layer.symbology", &json!({"layer":layer, "type":renderer, "field":"value"})).unwrap(); let l = s.map_mut().layer_mut(layer).unwrap(); l.labels.enabled = true; l.labels.default.field = "name".into(); l.labels.default.size = 19.; l.definition_query = Some("value > 0".into()); l.display_filters = ["value > 0", "keep = 1"] .map(|q| rustgis_map::effects::DisplayFilter { name: q.into(), min_scale: None, max_scale: None, where_: q.into() }) .into(); let style = l.renderer.clone(); let labels = l.labels.clone(); // Reordered and additional fields do not invalidate references by name. server.set(ITEMS, &features(json!({"extra":true, "name":"Updated", "value":20, "keep":1}))); s.run("service.refresh", &target).unwrap(); let singled = |s: &Session| s.notices.iter().filter(|n| n.text.contains("drawn with a single symbol (1: Places)")).count(); assert_eq!(singled(&s), 0); // Classes by an expression that still reads are kept too. s.run("layer.symbology", &json!({"layer":layer, "type":"unique", "expression":"value || ''"})).unwrap(); let by_expression = s.map().layer(layer).unwrap().renderer.clone(); s.run("service.refresh", &target).unwrap(); assert_eq!(s.map().layer(layer).unwrap().renderer, by_expression); assert_eq!(singled(&s), 0); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); let l = s.map().layer(layer).unwrap(); assert_eq!(l.renderer, style); assert_eq!(l.labels, labels); assert_eq!(l.definition_query.as_deref(), Some("value > 0")); assert_eq!(l.display_filters.len(), 2); // Geometry is unchanged, but fields used by the old configuration disappear. server.set(ITEMS, &features(json!({"replacement":"New", "keep":1}))); s.run("service.refresh", &target).unwrap(); let l = s.map().layer(layer).unwrap(); assert!(matches!(l.renderer, rustgis_map::Renderer::Single(_))); assert_eq!(singled(&s), 1, "the classes going is said"); // Classes by an expression on a field that goes too. s.run("edit.undo", &Value::Null).unwrap(); s.run("layer.symbology", &json!({"layer":layer, "type":"unique", "expression":"value || ''"})).unwrap(); s.run("service.refresh", &target).unwrap(); let l = s.map().layer(layer).unwrap(); assert_eq!(l.renderer, rustgis_map::Renderer::Single(rustgis_map::Symbol::default_for(l.geometry_type(), l.id))); assert_eq!(singled(&s), 2); s.run("edit.undo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); s.run("service.refresh", &target).unwrap(); let l = s.map().layer(layer).unwrap(); assert!(!l.labels.enabled); assert!(l.labels.default.field.is_empty()); assert_eq!(l.labels.default.size, 19.); assert!(l.definition_query.is_none()); assert_eq!(l.display_filters.len(), 1); assert_eq!(l.display_filters[0].where_, "keep = 1"); s.run("edit.undo", &Value::Null).unwrap(); let l = s.map().layer(layer).unwrap(); assert_eq!(l.renderer, style); assert_eq!(l.labels, labels); // Labels by a text expression that still reads stay on without their field. s.map_mut().layer_mut(layer).unwrap().labels.default.by.text = Some("'#' || keep".into()); server.set(ITEMS, &features(json!({"replacement":"New", "keep":1}))); s.run("service.refresh", &target).unwrap(); let l = s.map().layer(layer).unwrap(); assert!(l.labels.enabled && l.labels.default.by.text.is_some(), "{:?}", l.labels.default); assert!(l.labels.default.field.is_empty(), "the field the service dropped isn't kept"); let lost = |s: &Session| s.notices.iter().filter(|n| n.text.contains("label expressions that don't read")).count(); assert_eq!(lost(&s), 0, "every expression still reads"); s.run("edit.undo", &Value::Null).unwrap(); // A size by a field that goes is kept, said. s.map_mut().layer_mut(layer).unwrap().labels.default.by.size = Some("value / 2".into()); s.run("service.refresh", &target).unwrap(); assert!(s.notices.iter().any(|n| n.text.ends_with("left out when drawing (1, in Places)")), "{:?}", s.notices.last()); assert_eq!(s.map().layer(layer).unwrap().labels.default.by.size.as_deref(), Some("value / 2")); s.run("edit.undo", &Value::Null).unwrap(); let l = s.map_mut().layer_mut(layer).unwrap(); (l.labels.default.by.text, l.labels.default.by.size) = (None, None); // Another class labelling by a text expression that still reads keeps the labels on. let mut other = rustgis_map::LabelClass::default_for(&l.dataset); (other.name, other.field, other.by.text) = ("Other".into(), "name".into(), Some("'#' || keep".into())); l.labels.classes.push(other); s.run("service.refresh", &target).unwrap(); let l = s.map().layer(layer).unwrap(); assert!(l.labels.enabled && l.labels.default.field.is_empty(), "{:?}", l.labels); s.run("edit.undo", &Value::Null).unwrap(); // Hidden, or on a field that goes without a text, it doesn't. for (visible, text) in [(false, Some("'#' || keep")), (true, None)] { let c = &mut s.map_mut().layer_mut(layer).unwrap().labels.classes[0]; (c.visible, c.by.text) = (visible, text.map(String::from)); s.run("service.refresh", &target).unwrap(); assert!(!s.map().layer(layer).unwrap().labels.enabled, "visible {visible}, text {text:?}"); s.run("edit.undo", &Value::Null).unwrap(); } s.map_mut().layer_mut(layer).unwrap().labels.classes.clear(); // Numeric renderers cannot use a field which has become text; unique values can. server.set(ITEMS, &features(json!({"name":"Place", "value":"text", "keep":1}))); s.run("service.refresh", &target).unwrap(); assert_eq!(matches!(s.map().layer(layer).unwrap().renderer, rustgis_map::Renderer::Single(_)), renderer != "unique"); } } } #[test] fn layer_color_honours_an_explicit_fill_alpha() { let mut s = world(); let alpha = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.base_symbol().fill.a(); for a in ["00", "40", "FF"] { s.run("layer.color", &json!({"layer": "Countries", "color": format!("#336699{a}")})).unwrap(); assert_eq!(alpha(&s), u8::from_str_radix(a, 16).unwrap(), "alpha {a} from the colour picker is kept"); } // A plain #RRGGBB keeps the layer's translucent fill. s.run("layer.color", &json!({"layer": "Countries", "color": "#336699C8"})).unwrap(); s.run("layer.color", &json!({"layer": "Countries", "color": "#996633"})).unwrap(); assert_eq!(alpha(&s), 0xC8); // It never makes the fill fainter than the floor `with_fill` gives a polygon. s.run("layer.color", &json!({"layer": "Countries", "color": "#33669940"})).unwrap(); s.run("layer.color", &json!({"layer": "Countries", "color": "#996633"})).unwrap(); assert_eq!(alpha(&s), 150); } #[test] fn layer_color_leaves_the_outline_alone() { let mut s = world(); let symbol = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.base_symbol(); let rgb = |c: rustgis_map::Rgba| (c.r(), c.g(), c.b()); // An outline that was never set stays as it was too. let default_stroke = symbol(&s).stroke; s.run("layer.color", &json!({"layer": "Countries", "color": "#336699"})).unwrap(); assert_eq!(symbol(&s).stroke, default_stroke, "a default outline is not re-derived from the new fill"); // A chosen outline survives a fill change. s.run("layer.outline", &json!({"layer": "Countries", "color": "#00FF00"})).unwrap(); let before = symbol(&s).stroke; s.run("layer.color", &json!({"layer": "Countries", "color": "#996633"})).unwrap(); assert_eq!(symbol(&s).stroke, before, "changing the fill must not change a chosen outline"); assert_eq!(rgb(symbol(&s).fill), (0x99, 0x66, 0x33), "changing the fill must set the requested RGB"); } /// No Fill preserves renderer classes and outlines across persistence and one-step undo/redo. #[test] fn polygon_no_fill_keeps_classes_and_outlines_and_undoes_as_one_step() { use rustgis_map::Symbol; for kind in ["single", "unique", "graduated"] { let mut s = world(); let field = if kind == "unique" { "continent" } else { "pop_est" }; s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field})).unwrap(); s.run("layer.outline", &json!({"layer": "Countries", "color": "#2376A9CC", "width": 2.5})).unwrap(); let renderer = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.clone(); let before = renderer(&s); // The named polygon layer works even while a point layer is active. s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); s.run("layer.noFill", &json!({"layer": "Countries"})).unwrap(); let after = renderer(&s); assert_eq!(after.kind_label(), before.kind_label(), "{kind}: classification stays in place"); let (old, new) = (before.legend(), after.legend()); assert_eq!(new.len(), old.len()); for (old, new) in old.iter().zip(&new) { assert_eq!(new.label, old.label); assert_eq!(new.symbol, Symbol { fill: old.symbol.fill.with_alpha(0), ..old.symbol }); } let reopened = Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert_eq!(reopened.map().layer_by_name("Countries").unwrap().renderer, after); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(renderer(&s), before, "{kind}: one undo restores every symbol"); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(renderer(&s), after); s.run("layer.noFill", &json!({"layer": "Countries"})).unwrap(); assert_eq!(renderer(&s), after, "No Fill is idempotent"); } } /// Unsupported or missing layers are refused without changing the project. #[test] fn polygon_no_fill_refuses_other_layers() { let mut s = with_raster(); let raster = s.active_layer.unwrap(); for layer in [json!("Cities"), json!("Rivers"), json!(raster), json!(99999)] { let before = serde_json::to_value(&s.project).unwrap(); assert!(s.run("layer.noFill", &json!({"layer": layer})).is_err()); assert_eq!(serde_json::to_value(&s.project).unwrap(), before); } assert!(Session::new(Project::default()).run("layer.noFill", &Value::Null).is_err()); } /// Polygon styling follows the actual GeoJSON features, not just their dominant family. #[test] fn mixed_geometry_no_fill_preserves_other_symbols_classes_and_undo() { use rustgis_data::GeometryType; let state = |project: &Project| { let mut value = serde_json::to_value(project).unwrap(); // Opening and undo/redo bump derived geometry revisions, independently of saved styles. for map in value["maps"].as_array_mut().unwrap() { for layer in map["layers"].as_array_mut().unwrap() { layer.as_object_mut().unwrap().remove("geometry_rev"); } } value }; let geometries = [ json!({"type": "Point", "coordinates": [-2, 0]}), json!({"type": "LineString", "coordinates": [[1.5, -0.5], [2.5, 0.5]]}), json!({"type": "Polygon", "coordinates": [[[-0.5,-0.5],[0.5,-0.5],[0.5,0.5],[-0.5,0.5],[-0.5,-0.5]]]}), ]; for (dominant, kind) in [GeometryType::Point, GeometryType::Polyline, GeometryType::Polygon].into_iter().enumerate() { for renderer in ["single", "unique", "graduated"] { let features: Vec = [0, 1, 2, dominant, dominant] .into_iter() .enumerate() .map(|(i, g)| json!({"type":"Feature", "geometry":geometries[g], "properties":{"class":i % 2, "value":i}})) .collect(); let mut s = Session::new(Project::default()); s.run( "layer.addDataText", &json!({"fileName":"mixed.geojson", "text":json!({"type":"FeatureCollection", "features":features}).to_string()}), ) .unwrap(); let id = s.active_layer.unwrap(); assert_eq!(s.map().layer(id).unwrap().geometry_type(), kind); s.run("layer.symbology", &json!({"type":renderer, "field":if renderer == "unique" { "class" } else { "value" }, "classes":2})) .unwrap(); // Both layer-wide and class-specific fills must stop drawing, while shared // symbols (point interiors, line markers, outlines) keep their original colours. s.run("layer.outline", &json!({"color":"#234567", "width":2.5})).unwrap(); s.run("layer.fillLayers", &json!({"fills":[{"color":"#FF0000"}]})).unwrap(); if renderer != "single" { let mut p = json!({"fills":[{"color":"#00FF00"}]}); if renderer == "unique" { p["value"] = json!(0); } else { p["index"] = json!(0); } s.run("layer.fillLayers", &p).unwrap(); } let view = crate::render::View { width: 400.0, height: 150.0, pixels_per_point: 1.0, sky: false, camera: rustgis_map::Camera { center: rustgis_geom::Coord::new(0.0, 0.0), res: 2000.0, bearing: 0.0, pitch: 0.0 }, }; s.map_mut().basemap = rustgis_map::Basemap::Blank; let drawn_before = (kind == GeometryType::Point && renderer == "single").then(|| crate::render::render(s.map_mut(), &view).unwrap().pixmap); let before = state(&s.project); let symbols: Vec<_> = (0..features.len()).map(|row| s.map().layer(id).unwrap().symbol(row)).collect(); s.run("layer.noFill", &json!({"layer":id})).unwrap(); let l = s.map().layer(id).unwrap(); for (row, symbol) in symbols.iter().enumerate() { assert_eq!(&l.symbol(row), symbol, "{kind:?}/{renderer}: shared symbols stay intact"); assert!(l.fills(row).iter().all(|f| !f.draws(symbol.fill)), "{kind:?}/{renderer}: polygon fills are transparent"); } if let Some(drawn_before) = drawn_before { let drawn_after = crate::render::render(s.map_mut(), &view).unwrap().pixmap; assert_ne!(drawn_before.pixel(200, 75), drawn_after.pixel(200, 75), "the polygon interior changes"); let middle = drawn_after.pixel(200, 75).unwrap(); assert_eq!((middle.red(), middle.green(), middle.blue()), (255, 255, 255), "the polygon interior reveals the basemap"); for y in 0..150 { for x in (0..165).chain(235..400) { assert_eq!(drawn_before.pixel(x, y), drawn_after.pixel(x, y), "points and lines stay unchanged at {x},{y}"); } } let edge = drawn_after.pixel(172, 75).unwrap(); assert!(edge.red() < 250 || edge.green() < 250 || edge.blue() < 250, "the polygon outline remains visible"); } let after = state(&s.project); let reopened = Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); assert_eq!(state(&reopened), after); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(state(&s.project), before); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(state(&s.project), after); s.run("layer.noFill", &Value::Null).unwrap(); assert_eq!(state(&s.project), after, "No Fill is idempotent"); // Removing the polygon-only override restores fills without touching other symbols. s.run("layer.fillLayers", &json!({"fills":[]})).unwrap(); assert!(s.map().layer(id).unwrap().fills(2).is_empty()); assert_eq!(s.map().layer(id).unwrap().symbol(0), symbols[0]); } } } /// After No Fill, a new symbology or a fill in the symbol's colour draws fills again (#1301), on /// a layer read into memory (its symbols' fills made transparent) and on a streamed or mixed one /// (a transparent fill over symbols that points and lines share) alike. Gradient fills, with /// colours of their own, always did. #[test] fn fills_come_back_after_no_fill() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; let square = |x: f64| { Geometry::Polygon(vec![vec![vec![ Coord::new(x, 0.0), Coord::new(x + 1.0, 0.0), Coord::new(x + 1.0, 1.0), Coord::new(x, 1.0), Coord::new(x, 0.0), ]]]) }; let dataset = |mixed: bool| { let mut ds = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text), Field::new("n", FieldType::Integer)]); for (i, kind) in ["a", "b", "a"].into_iter().enumerate() { ds.push(Some(square(i as f64 * 2.0)), vec![Attr::Text(kind.into()), Attr::Int(i as i64)]); } if mixed { ds.push(Some(Geometry::Point(Coord::new(10.0, 0.5))), vec![Attr::Text("a".into()), Attr::Int(9)]); } ds }; // Whether the polygons draw a fill: each of them, or, with none loaded (a streamed layer // away from its features), the layer's own fills over its symbol. let filled = |s: &Session| { let l = s.map().layer(s.active_layer.unwrap()).unwrap(); let draws = |fills: &[rustgis_map::fills::FillLayer], fill: rustgis_map::Rgba| { if fills.is_empty() { fill.a() > 0 } else { fills.iter().any(|f| f.draws(fill)) } }; let polygons: Vec = (0..l.dataset.features.len()).filter(|r| matches!(l.dataset.features[*r].geometry, Some(Geometry::Polygon(_)))).collect(); if polygons.is_empty() { draws(&l.fill_layers, l.renderer.base_symbol().fill) } else { polygons.iter().all(|r| draws(l.fills(*r), l.symbol(*r).fill)) } }; for case in ["memory", "mixed", "streamed"] { let mut s = Session::new(Project::default()); if case == "streamed" { let path = std::env::temp_dir().join(format!("rustgis-engine-{}-fill-after-no-fill.geojson", std::process::id())); std::fs::write(&path, rustgis_data::io::to_geojson(&dataset(false)).to_string()).unwrap(); s.run("layer.addData", &json!({"path": path.to_string_lossy(), "stream": true})).unwrap(); let _ = std::fs::remove_file(path); let id = s.active_layer.unwrap(); let l = s.map_mut().layer_mut(id).unwrap(); assert!(l.is_streamed()); l.dataset.features.clear(); } else { let id = s.map_mut().add_dataset(dataset(case == "mixed")); s.active_layer = Some(id); } let id = s.active_layer.unwrap(); assert!(filled(&s), "{case}: filled to begin with"); let steps = [ json!({"command": "layer.restoreFill", "params": {}}), json!({"command": "layer.symbology", "params": {"type": "single"}}), json!({"command": "layer.symbology", "params": {"type": "unique", "field": "kind"}}), // Again on the same field and scheme, which carries the classes over. json!({"command": "layer.symbology", "params": {"type": "unique", "field": "kind"}}), json!({"command": "layer.restoreFill", "params": {}}), json!({"command": "layer.symbology", "params": {"type": "graduated", "field": "n", "classes": 2}}), json!({"command": "layer.symbology", "params": {"type": "graduated", "field": "n", "classes": 2}}), json!({"command": "layer.restoreFill", "params": {}}), json!({"command": "layer.ramp", "params": {"ramp": "Harbour"}}), json!({"command": "layer.color", "params": {"color": "#336699"}}), json!({"command": "layer.fillLayers", "params": {"fills": [{"pattern": "solid"}]}}), json!({"command": "layer.fillLayers", "params": {"fills": [{"pattern": "hatch", "angle": 45}]}}), json!({"command": "layer.fillLayers", "params": {"fills": []}}), json!({"command": "layer.fillLayers", "params": {"fills": [{"pattern": "gradient", "color": "#336699"}]}}), ]; // Graduated classes need values, which the streamed layer hasn't loaded. for step in steps.into_iter().filter(|st| case != "streamed" || st["params"]["type"] != "graduated") { s.run("layer.noFill", &Value::Null).unwrap(); assert!(!filled(&s), "{case}: No Fill hides the fills before {step}"); // The path each layer takes: a transparent fill over the symbols of a streamed or // mixed layer, the symbols' own fills made transparent on the other. let l = s.map().layer(id).unwrap(); let over = matches!(l.fill_layers.as_slice(), [f] if f.color.is_some_and(|c| c.a() == 0)); assert_eq!(over, case != "memory", "{case}: No Fill's path"); assert_eq!(l.no_fill, Some(if over { rustgis_map::NoFill::Override } else { rustgis_map::NoFill::Symbols })); s.run(step["command"].as_str().unwrap(), &step["params"]).unwrap(); assert!(filled(&s), "{case}: {step} after No Fill draws fills"); if step["command"] == "layer.restoreFill" { assert!(s.map().layer(id).unwrap().no_fill.is_none()); s.run("edit.undo", &Value::Null).unwrap(); assert!(!filled(&s), "{case}: undo restores No Fill"); let marker = Some(if over { rustgis_map::NoFill::Override } else { rustgis_map::NoFill::Symbols }); assert_eq!(s.map().layer(id).unwrap().no_fill, marker, "{case}: undo restores No Fill's marker"); s.run("edit.redo", &Value::Null).unwrap(); assert!(filled(&s), "{case}: redo restores fills"); } } // A fill given its own transparent colour leaves the polygons unfilled. for colour in ["#11223300", "#00000000"] { s.run("layer.symbology", &json!({"type": "single"})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "solid", "color": colour}]})).unwrap(); assert!(!filled(&s), "{case}: a transparent fill {colour} stays transparent"); // A new symbology then keeps that fill and turns No Fill off, showing the symbols' // fills under it. s.run("layer.symbology", &json!({"type": "single"})).unwrap(); let l = s.map().layer(id).unwrap(); assert!( matches!(l.fill_layers.as_slice(), [f] if f.color.is_some_and(|c| c.a() == 0)), "{case}: the chosen fill {colour} stays" ); assert_eq!(l.no_fill, None, "{case}: No Fill is off"); assert!(l.renderer.base_symbol().fill.a() > 0, "{case}: the symbol's fill is shown"); } // One class's fills after No Fill show that class alone: a hatch, or its symbol's own // fill ([]). The streamed layer has no rows loaded to look at. if case != "streamed" { // Without the transparent layer fill chosen above, which a class without fills takes. s.run("layer.fillLayers", &json!({"fills": []})).unwrap(); for fills in [json!([{"pattern": "hatch"}]), json!([])] { s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"value": "a", "fills": fills})).unwrap(); let l = s.map().layer(id).unwrap(); let shows = |row: usize| { l.fills(row).iter().any(|f| f.draws(l.symbol(row).fill)) || (l.fills(row).is_empty() && l.symbol(row).fill.a() > 0) }; assert!(shows(0) && shows(2), "{case}: class a shows with {fills}"); assert!(!shows(1), "{case}: class b keeps No Fill with {fills}"); } } } // A fill colour chosen transparent isn't No Fill: a new symbology keeps it. let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(dataset(false)); s.active_layer = Some(id); s.run("layer.color", &json!({"color": "#33669900"})).unwrap(); s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); assert!(!filled(&s), "a chosen transparent fill stays"); assert!(s.run("layer.restoreFill", &Value::Null).is_err(), "Restore Fill is off without No Fill"); assert!(!filled(&s), "an explicit transparent colour stays"); } /// #1299: Restore Fill puts back what No Fill hid, as it was: each fill's opacity, a fill made /// transparent before No Fill, a class's own fills and the layer's, for Unique Values and /// Graduated Colors (with a class's label and strokes), also after the project is saved and opened /// again, saying nothing; a colour chosen while No Fill is on stays. A project that kept nothing /// says what it couldn't put back. Off, it changes nothing. #[test] fn restore_fill_puts_back_what_no_fill_hid() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; use rustgis_map::symbology::ClassRef; for (mixed, unique) in [(false, true), (true, true), (false, false), (true, false)] { let case = format!("mixed {mixed}, unique {unique}"); let mut ds = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text), Field::new("n", FieldType::Integer)]); for (i, kind) in ["a", "b", "c"].into_iter().enumerate() { let x = i as f64 * 2.0; let ring = vec![Coord::new(x, 0.0), Coord::new(x + 1.0, 0.0), Coord::new(x + 1.0, 1.0), Coord::new(x, 0.0)]; ds.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Attr::Text(kind.into()), Attr::Int(i as i64)]); } if mixed { ds.push(Some(Geometry::Point(Coord::new(10.0, 0.5))), vec![Attr::Text("a".into()), Attr::Int(1)]); } let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); let class = |i: usize, with: Value| { let value = ["a", "b", "c"][i]; let mut p = if unique { json!({"value": value}) } else { json!({"index": i}) }; p.as_object_mut().unwrap().extend(with.as_object().unwrap().clone()); p }; if unique { s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); } else { s.run("layer.symbology", &json!({"type": "graduated", "field": "n", "classes": 3, "method": "equalInterval"})).unwrap(); let r = &mut s.map_mut().layer_mut(id).unwrap().renderer; r.set_class_strokes(&ClassRef::Index(1), vec![rustgis_map::LineStroke::default()]).unwrap(); } s.run("layer.classStyle", &class(0, json!({"color": "#33669964", "label": "first"}))).unwrap(); s.run("layer.classStyle", &class(1, json!({"color": "#33669900"}))).unwrap(); s.run("layer.fillLayers", &class(0, json!({"fills": [{"pattern": "hatch", "angle": 45}]}))).unwrap(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "angle": 135}]})).unwrap(); let before = s.map().layer(id).unwrap().clone(); // Off, it is refused before it runs: no undo step, so the redo one stays. s.run("layer.outline", &json!({"color": "#000000"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.run("layer.restoreFill", &Value::Null).is_err(), "{case}: off"); s.run("edit.redo", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); // Again changes nothing it keeps. s.run("layer.noFill", &Value::Null).unwrap(); // Saved and opened again: once as this version saves it, once as an earlier one did. let saved = serde_json::to_value(&s.project).unwrap(); let mut earlier = saved.clone(); earlier["maps"][0]["layers"].as_array_mut().unwrap().iter_mut().for_each(|l| { l.as_object_mut().unwrap().remove("hidden_fills"); }); let mut s = Session::new(serde_json::from_value(saved).unwrap()); s.active_layer = Some(id); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(s.notices_since(mark).is_empty(), "{case}: {:?}", s.notices_since(mark)); let l = s.map().layer(id).unwrap(); assert_eq!(l.renderer, before.renderer, "{case}: the classes as they were"); assert_eq!(l.fill_layers, before.fill_layers, "{case}: the layer's fills"); assert!(l.no_fill.is_none() && l.hidden_fills.is_none(), "{case}: off"); assert!(s.run("layer.restoreFill", &Value::Null).is_err(), "{case}: off again"); // A class made transparent while No Fill is on stays so; the others come back. s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.classStyle", &class(2, json!({"color": "#11223300"}))).unwrap(); s.run("layer.restoreFill", &Value::Null).unwrap(); let mut now = Vec::new(); s.map_mut().layer_mut(id).unwrap().renderer.map_symbols(|sym| now.push(*sym)); let mut was = Vec::new(); before.renderer.clone().map_symbols(|sym| was.push(*sym)); assert_eq!(now[2].fill.a(), 0, "{case}: the third class stays transparent"); assert_eq!(now[..2], was[..2], "{case}: the others as they were"); // Kept by nothing: what it couldn't put back is said. let mut s = Session::new(serde_json::from_value(earlier).unwrap()); s.active_layer = Some(id); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); // Every symbol No Fill made transparent: Unique Values' too. let what = if mixed { "fill layers".to_string() } else { format!("{} fill(s) at the default opacity", if unique { 4 } else { 3 }) }; assert!(said.len() == 1 && said[0].contains(&what), "{case}: {said:?}"); } } /// No Fill again, on a layer it already hides (here one with points, so a transparent fill over /// its symbols), changes nothing: a class's fills set while it is on stay, and Restore Fill puts /// back what it hid, with no notice when nothing was changed since. #[test] fn no_fill_again_keeps_what_was_set_while_it_is_on() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; use rustgis_map::symbology::ClassRef; let mut ds = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text)]); for (i, kind) in ["a", "b"].into_iter().enumerate() { let x = i as f64 * 2.0; let ring = vec![Coord::new(x, 0.0), Coord::new(x + 1.0, 0.0), Coord::new(x + 1.0, 1.0), Coord::new(x, 0.0)]; ds.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Attr::Text(kind.into())]); } ds.push(Some(Geometry::Point(Coord::new(10.0, 0.5))), vec![Attr::Text("a".into())]); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "hatch"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); let a = ClassRef::Value("a".into()); // No Fill on again hides the hatch too, keeping it for Restore Fill. assert!(s.map().layer(id).unwrap().renderer.class_fills(&a).unwrap().is_empty(), "the class's hatch is hidden"); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(s.notices_since(mark).is_empty(), "{:?}", s.notices_since(mark)); assert_eq!(s.map().layer(id).unwrap().renderer.class_fills(&a).unwrap().len(), 1, "the class's hatch after Restore Fill"); assert!(s.map().layer(id).unwrap().no_fill.is_none(), "No Fill is off"); } /// A layer of three polygons in Unique Values classes "a", "b" and "c", with a point too when /// `mixed` (No Fill then overrides the polygons' fills instead of the symbols'). fn no_fill_layer(mixed: bool) -> (Session, u32) { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; let mut ds = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text)]); for (i, kind) in ["a", "b", "c"].into_iter().enumerate() { let x = i as f64 * 2.0; let ring = vec![Coord::new(x, 0.0), Coord::new(x + 1.0, 0.0), Coord::new(x + 1.0, 1.0), Coord::new(x, 0.0)]; ds.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Attr::Text(kind.into())]); } if mixed { ds.push(Some(Geometry::Point(Coord::new(10.0, 0.5))), vec![Attr::Text("a".into())]); } let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); (s, id) } /// No Fill again while it is on changes nothing, so it is no step: redo, undo and the saved state /// are left as they were. #[test] fn no_fill_again_is_no_step_to_undo() { let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.outline", &json!({"color": "#112233"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); let (undo, redo, dirty) = (s.undo.len(), s.redo.len(), s.dirty()); assert_eq!(redo, 1); s.run("layer.noFill", &Value::Null).unwrap(); assert_eq!((s.undo.len(), s.redo.len(), s.dirty()), (undo, redo, dirty)); assert!(s.map().layer(id).unwrap().no_fill.is_some()); } /// No Fill on again with nothing shown since keeps no record, so a project saved by an earlier /// RustGIS (No Fill on, nothing kept) still says nothing was kept. #[test] fn no_fill_again_without_a_change_starts_no_record_of_what_was_hidden() { let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); s.map_mut().layer_mut(id).unwrap().hidden_fills = None; s.run("layer.noFill", &Value::Null).unwrap(); assert!(s.map().layer(id).unwrap().hidden_fills.is_none()); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("wasn't kept")), "{said:?}"); } /// Restore Fill naming a layer without No Fill, while the active layer has it, is refused (the /// condition checks the named layer) and changes nothing: no step to undo, redo kept. #[test] fn restore_fill_on_a_layer_without_no_fill_is_no_step_to_undo() { use rustgis_data::{Dataset, GeometryType}; let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); let other = s.map_mut().add_dataset(Dataset::new("other", GeometryType::Polygon, Vec::new())); let (undo, redo, dirty) = (s.undo.len(), s.redo.len(), s.dirty()); let refused = s.run("layer.restoreFill", &json!({"layer": other})); assert!(refused.is_err_and(|e| e.contains("No Fill isn't on")), "the named layer has no No Fill"); assert_eq!((s.undo.len(), s.redo.len(), s.dirty()), (undo, redo, dirty)); assert!(s.map().layer(id).unwrap().no_fill.is_some()); } /// A record No Fill on again starts over a project that kept nothing (No Fill saved on by an /// earlier RustGIS) holds only what it hid, so Restore Fill still says the rest wasn't kept. #[test] fn no_fill_again_over_nothing_kept_still_says_what_wasnt_kept() { for mixed in [false, true] { let (mut s, id) = no_fill_layer(mixed); s.run("layer.noFill", &Value::Null).unwrap(); s.map_mut().layer_mut(id).unwrap().hidden_fills = None; s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "hatch"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); assert!(s.map().layer(id).unwrap().hidden_fills.as_ref().is_some_and(|h| h.partial), "mixed {mixed}"); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); let note = if mixed { "didn't keep them" } else { "wasn't kept" }; assert!(said.iter().any(|t| t.contains(note)), "mixed {mixed}: {said:?}"); assert!(!said.iter().any(|t| t.contains("changed since")), "mixed {mixed}: {said:?}"); } } /// Applying a saved style or symbolizing by subtype gives the layer a new renderer, so No Fill /// ends, as with a new symbology: Restore Fill can't put the old fills onto the new classes. #[test] fn a_new_renderer_from_a_style_or_subtypes_ends_no_fill() { let (mut s, id) = no_fill_layer(false); s.run("style.new", &json!({"name": "plain"})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("style.apply", &json!({"name": "plain"})).unwrap(); let l = s.map().layer(id).unwrap(); assert!(l.no_fill.is_none() && l.hidden_fills.is_none()); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("turning No Fill off drops")), "{said:?}"); assert!(s.run("layer.restoreFill", &Value::Null).is_err()); let (mut s, id) = no_fill_layer(false); s.map_mut().layer_mut(id).unwrap().dataset.fields.push(rustgis_data::Field::new("code", rustgis_data::FieldType::Integer)); for f in &mut s.map_mut().layer_mut(id).unwrap().dataset.features { f.attrs.push(rustgis_data::Value::Int(1)); } s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("data.subtypes", &json!({"field": "code", "subtypes": [{"code": 1, "name": "one"}]})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("turning No Fill off drops")), "{said:?}"); let l = s.map().layer(id).unwrap(); assert!(l.no_fill.is_none() && l.hidden_fills.is_none()); } /// A class's fill colour chosen while No Fill is on is recorded for Restore Fill only while the /// saved opacities match the symbols: a record of other symbols is left as it is. #[test] fn class_style_leaves_a_mismatched_opacity_record_alone() { let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); s.map_mut().layer_mut(id).unwrap().hidden_fills.as_mut().unwrap().alphas = vec![0x11]; s.run("layer.classStyle", &json!({"value": "a", "color": "#336699"})).unwrap(); let hidden = s.map().layer(id).unwrap().hidden_fills.as_ref().unwrap(); assert_eq!(hidden.alphas, vec![0x11], "{:?}", hidden.alphas); } /// The layer's own fill layers No Fill hid don't replace fill layers set since; Restore Fill says /// it didn't put them back, and says nothing when the layer had none. #[test] fn restore_fill_says_when_the_layers_hidden_fill_layers_stay_hidden() { let (mut s, id) = no_fill_layer(false); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#00ff00"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#ff0000"}]})).unwrap(); let mark = s.notice_mark(); s.run("layer.noFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("kept 1 fill stack(s) shown since")), "the green hatch replaced: {said:?}"); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#0000ff"}]})).unwrap(); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("fill layer(s) No Fill hid")), "{said:?}"); assert_eq!(s.map().layer(id).unwrap().fill_layers.len(), 1); let (mut s, _) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(s.notices_since(mark).is_empty()); } /// No Fill hides fill layers in a colour of their own on the symbols path too (they draw whatever /// the symbol's opacity), the layer's and a class's, and Restore Fill puts them back. #[test] fn no_fill_hides_fill_layers_in_their_own_colour() { let (mut s, id) = no_fill_layer(false); s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "hatch", "color": "#ff0000"}]})).unwrap(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#00ff00"}]})).unwrap(); let before = s.map().layer(id).unwrap().clone(); s.run("layer.noFill", &Value::Null).unwrap(); let l = s.map().layer(id).unwrap(); assert_eq!(l.no_fill, Some(rustgis_map::NoFill::Symbols)); assert!(l.fill_layers.is_empty(), "the layer's own hatch is hidden"); assert!( matches!(&l.renderer, rustgis_map::Renderer::Unique { class_fills, .. } if class_fills.is_empty()), "the class hatch is hidden" ); s.run("layer.restoreFill", &Value::Null).unwrap(); let l = s.map().layer(id).unwrap(); assert_eq!(l.fill_layers, before.fill_layers); assert_eq!(l.renderer, before.renderer); } /// A style saved while No Fill is on keeps the fills No Fill hid: the class hatch and the symbols' /// opacities, as the layer had them. #[test] fn a_style_saved_during_no_fill_keeps_the_hidden_fills() { let (mut s, id) = no_fill_layer(false); s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "hatch", "color": "#ff0000"}]})).unwrap(); let before = s.map().layer(id).unwrap().renderer.clone(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("style.new", &json!({"name": "kept"})).unwrap(); let style = s.project.styles.iter().find(|t| t.name == "kept").unwrap(); assert_eq!(style.renderer, before); assert!(s.map().layer(id).unwrap().no_fill.is_some(), "the layer itself stays as it is"); // Clean: no notice. A class given fills of its own since: the hidden hatch is left out, said once. let mark = s.notice_mark(); s.run("style.new", &json!({"name": "clean"})).unwrap(); assert!(s.notices_since(mark).is_empty()); s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "dots"}]})).unwrap(); let mark = s.notice_mark(); s.run("style.new", &json!({"name": "dots"})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(said.iter().filter(|t| t.contains("couldn't be put back")).count(), 1, "{said:?}"); } /// A whole-layer fill in another colour, set while No Fill is on, is hidden by No Fill on again /// and comes back with Restore Fill (symbols path: the layer's own fills are drawn). #[test] fn no_fill_again_hides_a_layer_fill_set_while_it_is_on() { let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#ff0000"}]})).unwrap(); assert_eq!(s.map().layer(id).unwrap().fill_layers.len(), 1); s.run("layer.noFill", &Value::Null).unwrap(); assert!(s.map().layer(id).unwrap().fill_layers.is_empty()); s.run("layer.restoreFill", &Value::Null).unwrap(); assert_eq!(s.map().layer(id).unwrap().fill_layers.len(), 1, "the hatch comes back"); assert!(s.map().layer(id).unwrap().no_fill.is_none()); } /// Turning No Fill off with a colour scheme, or with fill layers for the whole layer, says how /// many hidden fills it drops, once; with nothing hidden it says nothing. #[test] fn turning_no_fill_off_says_what_it_drops() { let (mut s, id) = no_fill_layer(false); let mark = s.notice_mark(); s.run("layer.ramp", &json!({"ramp": "Ember"})).unwrap(); assert!(s.notices_since(mark).is_empty(), "nothing hidden, nothing dropped"); s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("layer.ramp", &json!({"ramp": "Ember"})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.len() == 1 && said[0].contains("drops"), "{said:?}"); assert!(s.map().layer(id).unwrap().hidden_fills.is_none()); // On a mixed layer, a whole-layer fill in any colour turns No Fill off, dropping the layer's // own fills it hid. let (mut s, id) = no_fill_layer(true); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "dots"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("layer.fillLayers", &json!({"fills": [{"pattern": "hatch", "color": "#ff0000"}]})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.len() == 1 && said[0].contains("drops"), "{said:?}"); assert!(s.map().layer(id).unwrap().no_fill.is_none()); // A fill colour given to the layer turns No Fill off and says what it drops, too. let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); let mark = s.notice_mark(); s.run("layer.color", &json!({"layer": id, "color": "#336699"})).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("drops")), "{said:?}"); assert!(s.map().layer(id).unwrap().no_fill.is_none()); } /// No Fill on again hides what was shown since, on both paths, as one undo step; Restore Fill then /// shows it as it was shown. #[test] fn no_fill_again_hides_what_was_shown_since() { use rustgis_map::symbology::ClassRef; // Symbols: a class's colour with an opacity, chosen while No Fill is on, shows that class. let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.classStyle", &json!({"value": "a", "color": "#11223380"})).unwrap(); let alphas = |s: &Session| { let mut out = Vec::new(); s.map().layer(id).unwrap().renderer.clone().map_symbols(|sym| out.push(sym.fill.a())); out }; assert!(alphas(&s).contains(&0x80)); let undo = s.undo.len(); s.run("layer.noFill", &Value::Null).unwrap(); assert!(alphas(&s).iter().all(|a| *a == 0), "{:?}", alphas(&s)); assert_eq!(s.undo.len(), undo + 1); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(alphas(&s).contains(&0x80), "{:?}", alphas(&s)); // Override: a class's fills set while No Fill is on are hidden, and kept in place of the // class's earlier ones. let (mut s, id) = no_fill_layer(true); let c = ClassRef::Value("c".into()); s.run("layer.fillLayers", &json!({"value": "c", "fills": [{"pattern": "hatch"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.fillLayers", &json!({"value": "c", "fills": [{"pattern": "dots"}, {"pattern": "hatch"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); assert!(s.map().layer(id).unwrap().renderer.class_fills(&c).unwrap().is_empty()); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(s.notices_since(mark).is_empty(), "{:?}", s.notices_since(mark)); assert_eq!(s.map().layer(id).unwrap().renderer.class_fills(&c).unwrap().len(), 2, "the dots and hatch set last"); } /// A QGIS style with symbols turns No Fill off as Apply Style does, saying what that drops; one /// without leaves it on. #[test] fn qgis_style_turns_no_fill_off_only_with_symbols() { let (mut s, id) = no_fill_layer(false); s.run("layer.noFill", &Value::Null).unwrap(); let qml = |renderer: &str| format!(r#""#); s.run("style.importQgis", &json!({"text": qml("heatmapRenderer")})).unwrap(); assert!(s.map().layer(id).unwrap().no_fill.is_some(), "no symbols in it: still on"); for (case, says) in [("on", true), ("already off", false)] { let mark = s.notice_mark(); s.run("style.importQgis", &json!({"text": qml("nullSymbol")})).unwrap(); assert!(s.map().layer(id).unwrap().no_fill.is_none(), "{case}: its symbols: off"); let said = s.notices_since(mark).iter().any(|n| n.text.contains("hidden fill")); assert_eq!(said, says, "{case}: the fills it drops are said"); } } /// Restore Fill after an undo puts back what No Fill hid, on both paths: the symbols' own /// opacities (one of them 0) and the fill stacks of the layer and its classes. #[test] fn restore_fill_after_an_undo_puts_back_the_style_from_before_no_fill() { for mixed in [false, true] { let (mut s, id) = no_fill_layer(mixed); s.run("layer.classStyle", &json!({"value": "a", "color": "#11223340"})).unwrap(); s.run("layer.classStyle", &json!({"value": "b", "color": "#44556600"})).unwrap(); if mixed { s.run("layer.fillLayers", &json!({"fills": [{"pattern": "dots"}]})).unwrap(); s.run("layer.fillLayers", &json!({"value": "c", "fills": [{"pattern": "hatch", "angle": 30}]})).unwrap(); } let style = |s: &Session| { let l = s.map().layer(id).unwrap(); (l.renderer.clone(), l.fill_layers.clone()) }; let before = style(&s); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.outline", &json!({"color": "#112233"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.map().layer(id).unwrap().hidden_fills.is_some(), "mixed: {mixed}"); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); assert!(s.notices_since(mark).is_empty(), "mixed: {mixed}: {:?}", s.notices_since(mark)); assert!(style(&s) == before, "mixed: {mixed}"); } } /// Restore Fill says when a class's hidden fills can't go back because the class has fills of its /// own since No Fill, once, with the count; the classes it could put back come back. #[test] fn restore_fill_says_when_a_class_was_given_fills_since() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; use rustgis_map::symbology::ClassRef; let mut ds = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text)]); for (i, kind) in ["a", "b"].into_iter().enumerate() { let x = i as f64 * 2.0; let ring = vec![Coord::new(x, 0.0), Coord::new(x + 1.0, 0.0), Coord::new(x + 1.0, 1.0), Coord::new(x, 0.0)]; ds.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Attr::Text(kind.into())]); } ds.push(Some(Geometry::Point(Coord::new(10.0, 0.5))), vec![Attr::Text("a".into())]); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s.run("layer.symbology", &json!({"type": "unique", "field": "kind"})).unwrap(); s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "hatch"}]})).unwrap(); s.run("layer.noFill", &Value::Null).unwrap(); // Class a has fills of its own again, so its hidden hatch can't go back over them. s.run("layer.fillLayers", &json!({"value": "a", "fills": [{"pattern": "dots"}]})).unwrap(); let mark = s.notice_mark(); s.run("layer.restoreFill", &Value::Null).unwrap(); let said: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.len() == 1 && said[0].contains("1 class"), "{said:?}"); let own = s.map().layer(id).unwrap().renderer.class_fills(&ClassRef::Value("a".into())).unwrap(); assert!( matches!(own, [f] if matches!(f.pattern, rustgis_map::fills::FillPattern::Dots { .. })), "class a keeps its own dots, not the hatch No Fill hid: {own:?}" ); } /// #1456: a definition query on a streamed source the view filters as it reads (FlatGeobuf) /// says nothing, nor on one only filtering what each view reads (GeoJSON read in place) when it /// holds no more than a view reads; past that, or when the view read is already a sample, or for /// a row number (which no view is walked with), it says the view may be a sample. #[test] fn definition_query_on_a_streamed_layer_says_when_it_filters_a_sample() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; let mut ds = Dataset::new("sites", GeometryType::Point, vec![Field::new("n", FieldType::Integer)]); ds.push(Some(Geometry::Point(Coord::new(1.0, 2.0))), vec![V::Int(1)]); ds.push(Some(Geometry::Point(Coord::new(2.0, 2.0))), vec![V::Int(2)]); let dir = std::env::temp_dir().join(format!("rustgis-engine-{}-query-sample", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let fgb = dir.join("sites.fgb"); std::fs::write(&fgb, rustgis_data::source::fgb::write(&ds).unwrap()).unwrap(); let geojson = dir.join("sites.geojson"); std::fs::write(&geojson, rustgis_data::io::to_geojson(&ds).to_string()).unwrap(); let mut s = Session::new(Project::default()); let view = rustgis_geom::Extent::new(0.0, 0.0, 3.0, 3.0); // The view's limit: 0 is the default, more than the two features; 1 is fewer. A sample // already read (a view of more bytes than it holds) counts, whatever the source's count. let cases = [ (&fgb, 1, false, "n = 1", false), (&fgb, 1, false, "@row_number = 1", true), (&geojson, 0, false, "n = 1", false), (&geojson, 1, false, "n = 1", true), (&geojson, 0, true, "n = 1", true), ]; for (path, limit, read_sample, query, sampled) in cases { s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap(); let id = s.active_layer.unwrap(); let l = s.map_mut().layer_mut(id).unwrap(); if read_sample { l.source.as_mut().unwrap().limit = 1; l.sync_source(&view, true); } l.source.as_mut().unwrap().limit = limit; let mark = s.notice_mark(); s.run("layer.definitionQuery", &json!({"where": query})).unwrap(); let said = s.notices_since(mark).iter().any(|n| n.text.contains("a sample where a view holds more")); assert_eq!(said, sampled, "{} reading {limit}, {query}: {:?}", path.display(), s.notices_since(mark)); // How much: the most a view reads of the layer's features. if sampled { let most = if limit == 0 { rustgis_map::source::VIEW_LIMIT } else { limit }; assert!(s.notices_since(mark).iter().any(|n| n.text.contains(&format!("at most {most} of the layer's 2 features")))); } } let _ = std::fs::remove_dir_all(&dir); } /// Other families can be outside a streamed polygon layer's current view when No Fill runs. #[test] fn mixed_geometry_no_fill_preserves_unloaded_point_symbols() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; let mut ds = Dataset::new("mixed", GeometryType::Polygon, vec![]); let polygon = Geometry::Polygon(vec![vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 0.0)]]]); ds.push(Some(polygon.clone()), vec![]); ds.push(Some(polygon), vec![]); ds.push(Some(Geometry::Point(Coord::new(100.0, 30.0))), vec![]); let path = std::env::temp_dir().join(format!("rustgis-engine-{}-mixed-no-fill.geojson", std::process::id())); std::fs::write(&path, rustgis_data::io::to_geojson(&ds).to_string()).unwrap(); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path":path.to_string_lossy(), "stream":true})).unwrap(); let id = s.active_layer.unwrap(); let l = s.map_mut().layer_mut(id).unwrap(); assert!(l.is_streamed()); l.dataset.features.clear(); let renderer = l.renderer.clone(); s.run("layer.noFill", &Value::Null).unwrap(); s.run("layer.loadAll", &Value::Null).unwrap(); let l = s.map().layer(id).unwrap(); assert_eq!(l.renderer, renderer, "the later point keeps its filled symbol"); assert!(l.symbol(2).fill.a() > 0); assert!(!l.fills(0).is_empty()); assert!(l.fills(0).iter().all(|f| !f.draws(l.symbol(0).fill))); let _ = std::fs::remove_file(path); } #[test] fn buffer_point_adds_only_the_buffer() { let mut s = world(); let before = s.map().layers.len(); let v = s.run("gp.bufferPoint", &json!({"lon": 2.35, "lat": 48.85, "distance": 1, "units": "Kilometers", "name": "Buffer 1 km"})).unwrap(); assert_eq!(s.map().layers.len(), before + 1, "the clicked point isn't added"); let l = s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap(); assert_eq!(l.name, "Buffer 1 km"); let e = l.dataset.extent(); assert!((e.max_y - e.min_y - 2.0 / 111.2).abs() < 0.002, "a 1 km radius: {e:?}"); assert!(s.run("gp.bufferPoint", &json!({"lon": 200, "lat": 0, "distance": 1})).is_err()); assert!(s.run("gp.bufferPoint", &json!({"lon": 0, "lat": 0})).is_err()); } #[test] fn raster_stretch_custom_bounds_per_band() { use rustgis_map::Stretch; let mut s = with_raster(); let layer = |s: &Session, i: usize| s.map().layers.get(i).and_then(|l| l.raster.as_ref()).unwrap().clone(); let stretch = |s: &Session| layer(s, 0).style.stretch; // Three bands with ranges of their own. let ranges = [[0.0, 100.0], [0.0, 200.0], [10.0, 50.0]]; { let rl = s.map_mut().layers.first_mut().and_then(|l| l.raster.as_mut()).unwrap(); let mut d = (**rl.data.as_ref().unwrap()).clone(); assert!(d.bands.len() >= 3); for (b, [lo, hi]) in d.bands.iter_mut().zip(ranges) { b.stats.min = lo; b.stats.max = hi; } rl.data = Some(std::sync::Arc::new(d)); } s.run("raster.symbology", &json!({"type": "rgb"})).unwrap(); s.run("raster.stretch", &json!({"type": "minMax"})).unwrap(); // Custom starts from the range each band is drawn over now. s.run("raster.stretch", &json!({"type": "custom"})).unwrap(); assert_eq!(stretch(&s), Stretch::custom_bands(ranges)); // A bound for each band; null keeps that band's current one. s.run("raster.stretch", &json!({"type": "custom", "min": [null, 5, null], "max": [90, null, null]})).unwrap(); assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 90.0], [5.0, 200.0], [10.0, 50.0]])); // A number is for every band. s.run("raster.stretch", &json!({"type": "custom", "min": 1, "max": 99})).unwrap(); assert_eq!(stretch(&s), Stretch::custom(1.0, 99.0)); s.run("raster.stretch", &json!({"type": "custom", "min": 1})).unwrap(); assert_eq!(stretch(&s), Stretch::custom_bands([[1.0, 99.0]; 3]), "the other bound is the range drawn now"); // Bad bounds are refused, naming the band, and leave the stretch alone. let before = stretch(&s); for p in [ json!({"type": "custom", "min": [300, null, null]}), json!({"type": "custom", "max": [null, null, 1]}), json!({"type": "custom", "min": [1, 2]}), json!({"type": "custom", "min": [1, "x", null]}), json!({"type": "custom", "min": [1, 2, 3, 4]}), ] { assert!(s.run("raster.stretch", &p).is_err(), "{p}"); assert_eq!(stretch(&s), before, "{p}"); } let e = s.run("raster.stretch", &json!({"type": "custom", "max": [null, null, 1]})).unwrap_err(); assert!(e.to_string().contains("blue"), "{e}"); // A project round trip keeps them. let json = serde_json::to_string(&stretch(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), stretch(&s)); // A channel moved to another band is redrawn over that band's range, the others keep theirs. s.run("raster.stretch", &json!({"type": "custom", "min": [2, 3, 12], "max": [80, 150, 40]})).unwrap(); s.run("raster.bands", &json!({"red": 2})).unwrap(); assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 200.0], [3.0, 150.0], [12.0, 40.0]])); s.run("raster.bands", &json!({"red": 2})).unwrap(); assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 200.0], [3.0, 150.0], [12.0, 40.0]]), "same bands, same bounds"); // Another stretch type leaves bands alone. s.run("raster.stretch", &json!({"type": "minMax"})).unwrap(); s.run("raster.bands", &json!({"red": 3})).unwrap(); assert_eq!(stretch(&s), Stretch::MinMax); // One band takes one pair only. s.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); assert!(s.run("raster.stretch", &json!({"type": "custom", "min": [1, 2, 3]})).is_err()); s.run("raster.stretch", &json!({"type": "custom", "min": 3, "max": 7})).unwrap(); assert_eq!(stretch(&s), Stretch::custom(3.0, 7.0)); } #[test] fn raster_stretch_like_copies_each_band() { use rustgis_map::Stretch; let mut s = with_raster(); s.run("raster.symbology", &json!({"type": "rgb"})).unwrap(); s.run("raster.stretch", &json!({"type": "custom", "min": [1, 2, 3], "max": [10, 20, 30]})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": utm_tiff_b64()})).unwrap(); let (from, to) = ("scene", "scene (2)"); s.run("raster.symbology", &json!({"layer": to, "type": "rgb"})).unwrap(); s.run("raster.stretch", &json!({"layer": to, "like": from})).unwrap(); let st = s.map().layer_by_name(to).and_then(|l| l.raster.as_ref()).unwrap().style.stretch; assert_eq!(st, Stretch::custom_bands([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]])); } #[test] fn raster_stretch_custom_bounds() { use rustgis_map::Stretch; let mut s = with_raster(); // One band: a missing bound is that band's own statistic (on RGB it is the range drawn now). s.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); let stretch = |s: &Session| s.map().layers.first().unwrap().raster.as_ref().unwrap().style.stretch; let stats = s.map().layers.first().unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].stats.clone(); s.run("raster.stretch", &json!({"type": "custom", "min": 10, "max": 200})).unwrap(); assert_eq!(stretch(&s), Stretch::custom(10.0, 200.0)); // A missing bound is the layer's own statistic. s.run("raster.stretch", &json!({"type": "custom", "min": 10})).unwrap(); assert_eq!(stretch(&s), Stretch::custom(10.0, stats.max)); // The bounds survive DRA and a project round trip. s.run("raster.dra", &json!({"on": true})).unwrap(); let kept = serde_json::to_string(&stretch(&s)).unwrap(); assert_eq!(serde_json::from_str::(&kept).unwrap(), stretch(&s)); // Bad bounds are refused and leave the stretch alone. let before = stretch(&s); assert!(s.run("raster.stretch", &json!({"type": "custom", "min": 5, "max": 5})).is_err()); assert!(s.run("raster.stretch", &json!({"type": "custom", "min": 9, "max": 1})).is_err()); assert!(s.run("raster.stretch", &json!({"type": "custom", "min": 1e308})).is_err()); // A bound that was given but isn't a number is refused, not replaced by the layer's own. assert!(s.run("raster.stretch", &json!({"type": "custom", "min": "NaN", "max": 100})).is_err()); assert!(s.run("raster.stretch", &json!({"type": "custom", "min": "abc"})).is_err()); assert_eq!(stretch(&s), before); // A band with one value still gets a range to edit from. let mut flat = with_raster(); flat.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); { let rl = flat.map_mut().layers.first_mut().and_then(|l| l.raster.as_mut()).unwrap(); let mut d = (**rl.data.as_ref().unwrap()).clone(); d.bands[0].stats.min = 7.0; d.bands[0].stats.max = 7.0; rl.data = Some(std::sync::Arc::new(d)); } flat.run("raster.stretch", &json!({"type": "custom"})).unwrap(); assert_eq!(stretch(&flat), Stretch::custom(6.5, 7.5)); // The padding follows the value's size, and statistics that aren't numbers are refused. let set_stats = |s: &mut Session, v: f64| { let rl = s.map_mut().layers.first_mut().and_then(|l| l.raster.as_mut()).unwrap(); let mut d = (**rl.data.as_ref().unwrap()).clone(); d.bands[0].stats.min = v; d.bands[0].stats.max = v; rl.data = Some(std::sync::Arc::new(d)); }; set_stats(&mut flat, 9007199254740992.0); flat.run("raster.stretch", &json!({"type": "custom"})).unwrap(); assert!(matches!(stretch(&flat), Stretch::Custom { min, max, .. } if min < 9007199254740992.0 && max > 9007199254740992.0)); let before = stretch(&flat); for v in [f64::NAN, f64::INFINITY] { set_stats(&mut flat, v); assert!(flat.run("raster.stretch", &json!({"type": "custom"})).is_err(), "{v}"); } assert_eq!(stretch(&flat), before); // Projects saved before the custom stretch still load. assert_eq!(serde_json::from_str::("\"MinMax\"").unwrap(), Stretch::MinMax); } #[test] fn default_raster_style_applies_to_new_rasters() { use rustgis_map::{RasterRenderer, Resampling, Stretch}; let mut s = world(); s.mark_saved(); let undo = s.undo_label(); // The layer just added (rasters go in under the vectors, the newest on top of them). let style = |s: &Session| s.map().layer(s.active_layer.unwrap()).unwrap().raster.as_ref().unwrap().style.clone(); // Without settings: the black-to-white grey stretch. let shown = s.run("raster.setDefaultStyle", &json!({})).unwrap(); assert_eq!(shown["ramp"], "gray"); assert_eq!(shown["invert"], false); let shown = s .run( "raster.setDefaultStyle", &json!({"ramp": "viridis", "invert": false, "stretch": "standardDeviation", "n": 2.5, "resampling": "bilinear"}), ) .unwrap(); assert_eq!(shown, json!({"ramp": "Viridis", "invert": false, "stretch": "standardDeviation", "n": 2.5, "resampling": "Bilinear"})); assert!(!s.dirty() && s.undo_label() == undo, "an app setting, not a project change"); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let st = style(&s); assert_eq!(st.renderer, RasterRenderer::Stretch { band: 0, ramp: rustgis_map::Ramp::Viridis, invert: false }); assert_eq!(st.stretch, Stretch::StandardDeviation { n: 2.5 }); assert_eq!(st.resampling, Resampling::Bilinear); // Colour images keep their colours. s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); assert!(matches!(style(&s).renderer, RasterRenderer::Rgb { .. }), "{:?}", style(&s).renderer); assert_eq!(style(&s).resampling, Resampling::Nearest); // From a layer: its ramp, inversion, stretch and resampling. s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64(), "name": "dem2"})).unwrap(); s.run("raster.symbology", &json!({"type": "stretch", "ramp": "Batlow", "invert": true})).unwrap(); s.run("raster.stretch", &json!({"type": "minMax"})).unwrap(); s.run("raster.resampling", &json!({"type": "nearest"})).unwrap(); let shown = s.run("raster.setDefaultStyle", &json!({"fromLayer": true})).unwrap(); assert_eq!(shown, json!({"ramp": "Batlow", "invert": true, "stretch": "minMax", "resampling": "Nearest"})); // A custom stretch is in one raster's units: refused either way, the settings unchanged. s.run("raster.stretch", &json!({"type": "custom", "min": 1, "max": 2})).unwrap(); assert!(s.run("raster.setDefaultStyle", &json!({"fromLayer": true})).is_err()); assert!(s.run("raster.setDefaultStyle", &json!({"stretch": "custom"})).is_err()); assert!(s.run("raster.setDefaultStyle", &json!({"ramp": "no such ramp"})).is_err()); assert!(s.run("raster.setDefaultStyle", &json!({"resampling": "cubic-ish"})).is_err()); s.run("raster.symbology", &json!({"type": "classify"})).unwrap(); assert!(s.run("raster.setDefaultStyle", &json!({"fromLayer": true})).is_err(), "only a stretch"); assert_eq!(s.raster_defaults.ramp, rustgis_map::Ramp::Batlow); // Reset: back to the grey stretch, and new rasters are drawn as before. s.run("raster.setDefaultStyle", &json!({"reset": true})).unwrap(); assert_eq!(s.raster_defaults, rustgis_map::RasterDefaults::default()); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64(), "name": "dem3"})).unwrap(); assert_eq!(style(&s).renderer, RasterRenderer::Stretch { band: 0, ramp: rustgis_map::Ramp::gray(), invert: false }); // An inverted stretch stays inverted when only its band changes (the pane sends no invert). let scene = |s: &Session| s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style.renderer.clone(); s.run("raster.symbology", &json!({"layer": "scene", "type": "stretch", "band": 1, "invert": true})).unwrap(); s.run("raster.symbology", &json!({"layer": "scene", "type": "stretch", "band": 2})).unwrap(); assert!(matches!(scene(&s), RasterRenderer::Stretch { band: 1, invert: true, .. }), "{:?}", scene(&s)); s.run("raster.symbology", &json!({"layer": "scene", "type": "classify"})).unwrap(); s.run("raster.symbology", &json!({"layer": "scene", "type": "stretch"})).unwrap(); assert!(matches!(scene(&s), RasterRenderer::Stretch { invert: false, .. }), "a new stretch isn't inverted"); } #[test] fn layer_groups_hold_related_layers_together() { use crate::render::View; let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); let depth = s.undo_depth(); let r = s.run("group.add", &json!({"name": "Places", "layers": ["Lakes", "Cities"]})).unwrap(); let g = r["group"].as_u64().unwrap() as u32; assert_eq!(s.undo_depth(), depth + 1); // Gathered at the topmost, in their order. assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); let id = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().id; assert_eq!(s.map().group(g).unwrap().children, [id(&s, "Cities"), id(&s, "Lakes")]); assert!(s.map().layer(g).is_none(), "a group's id is no layer's"); // Hiding the group hides its layers on the map (their own boxes stay ticked). s.run("map.goToXY", &json!({"lon": 10.0, "lat": 48.0, "scale": 20_000_000})).unwrap(); let png = |s: &mut Session| { let v = View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; crate::render::render(s.map_mut(), &v).unwrap().pixmap.data().to_vec() }; let shown = png(&mut s); for n in ["Cities", "Lakes"] { s.run("layer.visibility", &json!({"layer": n, "visible": false})).unwrap(); } let without = png(&mut s); assert_ne!(shown, without); for n in ["Cities", "Lakes"] { s.run("layer.visibility", &json!({"layer": n, "visible": true})).unwrap(); } s.run("group.set", &json!({"group": "places", "visible": false})).unwrap(); assert!(s.map().layer_by_name("Cities").unwrap().visible); assert!(!s.map().shows(s.map().layer_by_name("Cities").unwrap())); assert_eq!(png(&mut s), without); // Toggled back with no other property. assert_eq!(s.run("group.set", &json!({"group": g})).unwrap()["visible"], true); assert_eq!(png(&mut s), shown); // Its scale range applies on top of the layers' own: out beyond 1:1,000,000 at 1:20,000,000. s.run("group.scaleRange", &json!({"group": g, "min": 1_000_000})).unwrap(); assert_eq!(png(&mut s), without); s.run("group.scaleRange", &json!({"group": g})).unwrap(); // Fully see-through draws nothing; half see-through draws something in between. s.run("group.set", &json!({"group": g, "transparency": 100})).unwrap(); assert_eq!(png(&mut s), without); s.run("group.set", &json!({"group": g, "transparency": 50})).unwrap(); let half = png(&mut s); assert!(half != shown && half != without); s.run("group.set", &json!({"group": g, "transparency": 0, "name": "Water and towns"})).unwrap(); // Folding is a view change: no undo step. A visibility that isn't a bool is an error, not a toggle. let before = s.undo_depth(); s.run("group.expand", &json!({"group": g})).unwrap(); assert_eq!(s.undo_depth(), before); assert!(!s.map().group(g).unwrap().expanded); assert!(s.run("group.set", &json!({"group": g, "visible": "no"})).is_err()); assert!(s.run("group.set", &json!({"group": g, "name": 5})).is_err()); assert!(s.map().group(g).unwrap().visible); // Identify and the map's selection skip a hidden group's layers. s.run("group.set", &json!({"group": g, "visible": false})).unwrap(); let held = s.map().held_by_group(s.map().scale()); assert!(held.contains(&id(&s, "Cities")) && !held.contains(&id(&s, "Rivers"))); s.run("group.set", &json!({"group": g, "visible": true})).unwrap(); // Listed for scripts and the MCP server. let info = s.run("info.project", &Value::Null).unwrap(); assert_eq!(info["groups"][0]["name"], "Water and towns"); assert_eq!(info["groups"][0]["layers"].as_array().unwrap().len(), 2); // A layer moves in next to the others, and out again. s.run("layer.group", &json!({"layer": "Countries", "group": g})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Countries", "Rivers"]); s.run("layer.group", &json!({"layer": "Countries", "group": null})).unwrap(); assert_eq!(s.map().group(g).unwrap().children.len(), 2); assert!(s.run("layer.group", &json!({"layer": "Countries", "group": "nope"})).is_err()); assert!(s.run("layer.group", &json!({"layer": "Countries"})).is_err()); // A group's layer moves only within it; another layer moves past the group. assert!(s.run("layer.moveDown", &json!({"layer": "Lakes"})).is_err()); s.run("layer.moveUp", &json!({"layer": "Lakes"})).unwrap(); assert_eq!(names(&s), ["Lakes", "Cities", "Countries", "Rivers"]); assert!(s.run("layer.move", &json!({"layer": "Cities", "to": 3})).is_err()); s.run("layer.moveUp", &json!({"layer": "Countries"})).unwrap(); assert_eq!(names(&s), ["Countries", "Lakes", "Cities", "Rivers"]); s.run("layer.move", &json!({"layer": "Countries", "to": 1})).unwrap(); assert_eq!(names(&s), ["Lakes", "Cities", "Countries", "Rivers"]); // The group moves as a whole. s.run("group.move", &json!({"group": g, "to": 1})).unwrap(); assert_eq!(names(&s), ["Countries", "Lakes", "Cities", "Rivers"]); s.run("group.move", &json!({"group": g, "to": 1e9})).unwrap(); assert_eq!(names(&s), ["Countries", "Rivers", "Lakes", "Cities"]); s.run("group.move", &json!({"group": g, "to": 0})).unwrap(); assert!(s.run("group.move", &json!({"group": g})).is_err()); // Saved and opened again, with no dataset of its own. let dir = std::env::temp_dir().join(format!("rustgis-groups-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("groups.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); let mut t = Session::new(Project::default()); t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); let tg = t.map().group(g).unwrap(); assert_eq!((tg.name.as_str(), tg.expanded, tg.children.len()), ("Water and towns", false, 2)); std::fs::remove_dir_all(&dir).ok(); // Removing a layer takes it out of its group. s.run("layer.remove", &json!({"layer": "Lakes"})).unwrap(); assert_eq!(s.map().group(g).unwrap().children, [id(&s, "Cities")]); // Ungrouping keeps the layers; removing the group removes them. s.run("group.ungroup", &json!({"group": g})).unwrap(); assert!(s.map().groups.is_empty() && s.map().layer_by_name("Cities").is_some()); let g = s.run("group.add", &json!({"layers": ["Cities", "Rivers"]})).unwrap()["group"].as_u64().unwrap(); assert_eq!(s.map().groups[0].name, "New Group"); s.run("group.remove", &json!({"group": g})).unwrap(); assert_eq!(names(&s), ["Countries"]); // Undo brings everything back. while s.undo_depth() > depth { s.run("edit.undo", &Value::Null).unwrap(); } assert!(s.map().groups.is_empty()); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); } #[test] fn hand_edited_layer_groups_open_bounded_and_unambiguous() { let mut s = world(); s.run("group.add", &json!({"name": "A", "layers": ["Cities"]})).unwrap(); let mut v: Value = serde_json::from_str(&serde_json::to_string(&s.project).unwrap()).unwrap(); let map = &mut v["maps"][0]; let cities = s.map().layer_by_name("Cities").unwrap().id; let lakes = s.map().layer_by_name("Lakes").unwrap().id; // A group with no dataset parses; a child twice, a missing child, a layer's id, a zero id, // junk numbers and a group without children. map["groups"] = json!([ {"id": 900, "name": "Water", "children": [lakes, cities, 12345], "transparency": 1e9, "min_scale": -5.0}, {"id": 901, "name": "Again", "children": [lakes]}, {"id": cities, "name": "Clash", "children": []}, {"id": 0, "name": "Zero", "children": []}, {"id": 902, "name": "Empty"} ]); let p = Project::from_json(&v.to_string()).unwrap(); let m = p.maps.first().unwrap(); let names: Vec<&str> = m.groups.iter().map(|g| g.name.as_str()).collect(); assert_eq!(names, ["Water", "Again", "Empty"]); let water = &m.groups[0]; assert_eq!(water.children, [lakes, cities]); assert_eq!((water.transparency, water.min_scale, water.visible), (100.0, None, true)); assert!(m.groups[1].children.is_empty()); // Gathered: Lakes moved up under Cities, past Rivers. let order: Vec<&str> = m.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(order, ["Cities", "Lakes", "Rivers", "Countries"]); } #[test] fn a_new_layer_goes_above_a_group_rather_than_into_it() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); // A group at the top of the drawing order: a new layer goes on top (#808), above the group. s.run("group.add", &json!({"name": "Base", "layers": ["Cities", "Rivers"]})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); let square = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}]}"#; s.run("layer.addDataText", &json!({"fileName": "p.geojson", "text": square, "name": "Square"})).unwrap(); assert_eq!(names(&s), ["Square", "Cities", "Rivers", "Lakes", "Countries"]); assert!(s.map().groups[0].children.iter().all(|c| s.map().layer(*c).is_some_and(|l| l.name != "Square"))); } /// The drawing order of all the active map's layers, top first: map layers by name, tile layers /// as `~name`. fn stack_names(s: &Session) -> Vec { let m = s.map(); m.stack() .into_iter() .map(|d| match d { rustgis_map::Drawn::Layer(i) => m.layers[i].name.clone(), rustgis_map::Drawn::Tile(i) => format!("~{}", m.tiles[i].name), }) .collect() } const SQUARE: &str = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}]}"#; const POINT: &str = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[0,0]}}]}"#; /// Every new layer goes on top of the drawing order, whatever its kind, as GeoLibre adds them /// (#808): polygons over points, rasters over vectors, a web overlay (a WMS layer, Add Tile /// Layer's Overlay) over the map's layers, and a later layer over it. A basemap stays under them. #[test] fn new_layers_go_on_top_whatever_their_kind() { let mut s = world(); assert_eq!(stack_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); s.run("layer.addDataText", &json!({"fileName": "p.geojson", "text": SQUARE, "name": "Square"})).unwrap(); assert_eq!(stack_names(&s)[0], "Square", "a polygon layer over the points"); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": utm_tiff_b64()})).unwrap(); assert_eq!(stack_names(&s)[..2], ["scene", "Square"], "a raster over the vectors"); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); assert_eq!(stack_names(&s)[..2], ["~Depths", "scene"], "an overlay over the map's layers"); s.run("layer.addDataText", &json!({"fileName": "q.geojson", "text": POINT, "name": "Pin"})).unwrap(); assert_eq!(stack_names(&s)[..3], ["Pin", "~Depths", "scene"], "a later layer over the overlay"); assert_eq!(s.map().layer_by_name("Pin").map(|l| l.id), s.active_layer, "and active"); s.run("map.addTiles", &json!({"url": "https://base.example.org/{z}/{x}/{y}.png", "name": "Streets"})).unwrap(); assert_eq!(stack_names(&s).last().map(String::as_str), Some("~Streets"), "a basemap under them all"); // The layers already there kept their order. assert_eq!(stack_names(&s)[3..], ["Square", "Cities", "Rivers", "Lakes", "Countries", "~Streets"]); } /// A tile layer moves past map layers (a group's at once), by a step or to a place, and keeps its /// place when the layer under it moves or goes. #[test] fn tile_layers_move_among_the_map_layers() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); let mv = |s: &mut Session, to: Value| s.run("map.setTiles", &json!({"name": "Depths", "move": to})).map(|_| stack_names(s)); assert!(mv(&mut s, json!("up")).is_err(), "already on top"); assert_eq!(mv(&mut s, json!("down")).unwrap(), ["Cities", "~Depths", "Rivers", "Lakes", "Countries"]); s.run("group.add", &json!({"name": "Water", "layers": ["Rivers", "Lakes"]})).unwrap(); assert_eq!(mv(&mut s, json!("down")).unwrap(), ["Cities", "Rivers", "Lakes", "~Depths", "Countries"], "past the group"); assert_eq!(mv(&mut s, json!("up")).unwrap(), ["Cities", "~Depths", "Rivers", "Lakes", "Countries"]); assert_eq!(mv(&mut s, json!("bottom")).unwrap(), ["Cities", "Rivers", "Lakes", "Countries", "~Depths"]); assert!(mv(&mut s, json!("down")).is_err(), "already at the bottom"); assert_eq!(mv(&mut s, json!("top")).unwrap(), ["~Depths", "Cities", "Rivers", "Lakes", "Countries"]); // To a place among the other layers. s.run("map.setTiles", &json!({"name": "Depths", "to": 3})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "Rivers", "Lakes", "~Depths", "Countries"]); // Into a group's run: above the group. s.run("map.setTiles", &json!({"name": "Depths", "to": 2})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Rivers", "Lakes", "Countries"]); // The layer under it moves away, or goes: it stays where it was. s.run("map.setTiles", &json!({"name": "Depths", "to": 4})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "Rivers", "Lakes", "Countries", "~Depths"]); s.run("map.setTiles", &json!({"name": "Depths", "to": 3})).unwrap(); s.run("layer.move", &json!({"layer": "Countries", "to": 0})).unwrap(); assert_eq!(stack_names(&s), ["Countries", "Cities", "Rivers", "Lakes", "~Depths"]); s.run("map.setTiles", &json!({"name": "Depths", "to": 1})).unwrap(); assert_eq!(stack_names(&s), ["Countries", "~Depths", "Cities", "Rivers", "Lakes"]); s.run("layer.remove", &json!({"layer": "Cities"})).unwrap(); assert_eq!(stack_names(&s), ["Countries", "~Depths", "Rivers", "Lakes"]); s.undo(); assert_eq!(stack_names(&s), ["Countries", "~Depths", "Cities", "Rivers", "Lakes"]); assert!(s.run("map.setTiles", &json!({"name": "Depths", "move": "sideways"})).is_err()); for bad in [json!({"to": -1}), json!({"to": "3"}), json!({"to": 1, "move": "up"})] { let mut p = bad.clone(); p["name"] = json!("Depths"); assert!(s.run("map.setTiles", &p).is_err(), "{bad}"); } } /// A tile layer put into a group's run sits above the group, and stays there when the group is /// dissolved or a layer leaves it; the tile layers are listed in their drawing order (index 0 /// on top) whatever moves them. #[test] fn tile_layers_keep_their_place_when_groups_change() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://base.example.org/{z}/{x}/{y}.png", "name": "Streets"})).unwrap(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); s.run("group.add", &json!({"name": "Water", "layers": ["Rivers", "Lakes"]})).unwrap(); // Between Rivers and Lakes, inside the group's run. s.run("map.setTiles", &json!({"name": "Depths", "to": 2})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Rivers", "Lakes", "Countries", "~Streets"]); let group = s.map().groups[0].id; s.run("group.ungroup", &json!({"group": group})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Rivers", "Lakes", "Countries", "~Streets"]); s.undo(); s.run("layer.group", &json!({"layer": "Rivers", "group": null})).unwrap(); assert_eq!(stack_names(&s)[..2], ["Cities", "~Depths"]); // The basemap goes under the overlay: the list follows the drawing order. s.run("map.setTiles", &json!({"name": "Streets", "move": "top"})).unwrap(); let names: Vec<&str> = s.map().tiles.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, ["Streets", "Depths"]); s.run("map.setTiles", &json!({"index": 0, "move": "bottom"})).unwrap(); let names: Vec<&str> = s.map().tiles.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, ["Depths", "Streets"]); } /// A layer placed on its own spot, or a group moved to where it is, leaves the tile layers over /// them in their places: only a move hands them to the layer below. #[test] fn tile_layers_stay_over_a_layer_or_group_that_stays() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": "Depths", "to": 2})).unwrap(); let before = stack_names(&s); assert_eq!(before, ["Cities", "Rivers", "~Depths", "Lakes", "Countries"]); // Lakes placed just above Countries, where it is. s.run("layer.group", &json!({"layer": "Lakes", "group": null, "before": "Countries"})).unwrap(); assert_eq!(stack_names(&s), before); // A group of Lakes and Countries moved to its own place. s.run("group.add", &json!({"name": "Water", "layers": ["Lakes", "Countries"]})).unwrap(); let grouped = stack_names(&s); s.run("group.move", &json!({"group": "Water", "to": 2})).unwrap(); assert_eq!(stack_names(&s), grouped); // Moved, the group hands the tile layer over its layers on to the next layer down that // stays: none here, so it goes under all. s.run("group.move", &json!({"group": "Water", "to": 0})).unwrap(); assert_eq!(stack_names(&s), ["Lakes", "Countries", "Cities", "Rivers", "~Depths"]); } /// Two tile layers with no catalog id (and so the same one) keep their own places and order when /// a move leaves the layers where they were. #[test] fn tile_layers_without_ids_keep_their_places_when_nothing_moves() { let mut s = world(); for name in ["Upper", "Lower"] { s.run("map.addTiles", &json!({"url": format!("https://{name}.example.org/{{z}}/{{x}}/{{y}}.png"), "name": name, "overlay": true})) .unwrap(); } // Upper over Lakes (the layer placed next, on its own spot), Lower over Countries. s.run("map.setTiles", &json!({"name": "Upper", "to": 3})).unwrap(); s.run("map.setTiles", &json!({"name": "Lower", "to": 4})).unwrap(); for t in &mut s.map_mut().tiles { t.id.clear(); } let before = stack_names(&s); assert_eq!(before, ["Cities", "Rivers", "~Upper", "Lakes", "~Lower", "Countries"]); s.run("layer.group", &json!({"layer": "Lakes", "group": null, "before": "Countries"})).unwrap(); assert_eq!(stack_names(&s), before); } /// The tile layers' list follows the drawing order after a move that leaves the layers where /// they were, as after any other (map.tiles, the pop-ups and the AI Assistant read it). #[test] fn tile_layers_are_listed_in_drawing_order_after_a_move_in_place() { let mut s = world(); // (`to` is the place among the other layers: the tile layer goes above the one there.) for (i, (name, to)) in [("Over Countries", 3), ("Over Lakes", 2)].into_iter().enumerate() { let url = format!("https://tiles{i}.example.org/{{z}}/{{x}}/{{y}}.png"); s.run("map.addTiles", &json!({"url": url, "name": name, "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": name, "to": to})).unwrap(); } // The list out of the drawing order (as a change to the groups, which re-seats the tile // layers without sorting them, can leave it), then a layer placed where it is. s.map_mut().tiles.reverse(); s.run("layer.group", &json!({"layer": "Countries", "group": null, "before": null})).unwrap(); let in_stack: Vec = stack_names(&s).into_iter().filter_map(|n| n.strip_prefix('~').map(String::from)).collect(); let listed: Vec = s.map().tiles.iter().map(|t| t.name.clone()).collect(); assert_eq!(listed, in_stack); } /// Two tile layers whose places merge in a move that leaves the layers where they are (a layer /// put in the group below it) keep the order they were drawn in, even with the list out of order /// before (as a change to the groups can leave it). #[test] fn tile_layers_keep_their_drawing_order_when_their_places_merge_in_place() { let mut s = world(); for (i, (name, to)) in [("Over Rivers", 1), ("Over Lakes", 3)].into_iter().enumerate() { let url = format!("https://tiles{i}.example.org/{{z}}/{{x}}/{{y}}.png"); s.run("map.addTiles", &json!({"url": url, "name": name, "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": name, "to": to})).unwrap(); } assert_eq!(stack_names(&s), ["Cities", "~Over Rivers", "Rivers", "~Over Lakes", "Lakes", "Countries"]); s.run("group.add", &json!({"name": "Water", "layers": ["Lakes"]})).unwrap(); s.map_mut().tiles.reverse(); // Rivers joins the group, above Lakes, where it is: both tile layers now sit over it. s.run("layer.group", &json!({"layer": "Rivers", "group": "Water", "before": "Lakes"})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Over Rivers", "~Over Lakes", "Rivers", "Lakes", "Countries"]); } /// Layers sharing an id (a project may repeat one) are told apart by their places: a real move of /// one of them hands the tile layer over it on, as any move does. #[test] fn a_move_among_layers_sharing_an_id_still_hands_tile_layers_on() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": "Depths", "to": 0})).unwrap(); assert_eq!(stack_names(&s), ["~Depths", "Cities", "Rivers", "Lakes", "Countries"]); let m = s.map_mut(); let (x, lakes) = (m.layers[0].id, m.layers[2].id); m.layers[1].id = x; // Cities (the first with the id) goes above Lakes: the tile layer over it goes on to Lakes. m.place_layer(x, None, Some(lakes)).unwrap(); assert_eq!(stack_names(&s), ["Rivers", "Cities", "~Depths", "Lakes", "Countries"]); } /// A tile layer over a group, after a move inside the group, stays over it when the group's /// bottom layer is taken out in place: its place is the group's top as it is, not the layer /// that was the top before the move. #[test] fn a_tile_layer_over_a_group_stays_when_its_bottom_layer_leaves_in_place() { let mut s = world(); s.run("group.add", &json!({"name": "Water", "layers": ["Rivers", "Lakes"]})).unwrap(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": "Depths", "to": 1})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Rivers", "Lakes", "Countries"]); s.run("layer.move", &json!({"layer": "Lakes", "to": 1})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Lakes", "Rivers", "Countries"]); s.run("layer.group", &json!({"layer": "Rivers", "group": null, "before": "Countries"})).unwrap(); assert_eq!(stack_names(&s), ["Cities", "~Depths", "Lakes", "Rivers", "Countries"]); } /// A tile layer added as data (an archive, a GeoTIFF read in place) goes on top as an overlay /// does, without being one. #[test] fn a_tile_layer_added_as_data_goes_on_top() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://data.example.org/{z}/{x}/{y}.png", "name": "Archive", "top": true})).unwrap(); assert_eq!(stack_names(&s)[0], "~Archive"); assert!(!s.map().tiles[0].overlay); } /// A project saved before tile layers had places draws them under its layers, as then; a place /// is saved and read back. #[test] fn a_saved_projects_tile_layers_keep_their_place() { let mut s = world(); s.run("map.addTiles", &json!({"url": "https://wms.example.org/{z}/{x}/{y}.png", "name": "Depths", "overlay": true})).unwrap(); s.run("map.setTiles", &json!({"name": "Depths", "to": 2})).unwrap(); let placed = stack_names(&s); let mut v = serde_json::to_value(&s.project).unwrap(); let text = v.to_string(); let read = Session::new(Project::from_json(&text).unwrap()); assert_eq!(stack_names(&read), placed); // Without `over`, as saved before: under every layer. for t in v["maps"][0]["tiles"].as_array_mut().unwrap() { t.as_object_mut().unwrap().remove("over"); } let old = Session::new(Project::from_json(&v.to_string()).unwrap()); assert_eq!(stack_names(&old), ["Cities", "Rivers", "Lakes", "Countries", "~Depths"]); } #[test] fn a_layer_drops_into_and_out_of_a_group_at_a_place() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); let g = s.run("group.add", &json!({"name": "Water", "layers": ["Rivers", "Lakes"]})).unwrap()["group"].clone(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); let members = |s: &Session| { let g = &s.map().groups[0]; s.map().layers.iter().filter(|l| g.children.contains(&l.id)).map(|l| l.name.clone()).collect::>() }; let depth = s.undo_depth(); // Into the group, between its layers: one step. s.run("layer.group", &json!({"layer": "Countries", "group": g, "before": "Lakes"})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Countries", "Lakes"]); assert_eq!(members(&s), ["Rivers", "Countries", "Lakes"]); assert_eq!(s.undo_depth(), depth + 1); // Out of it, to the top. s.run("layer.group", &json!({"layer": "Rivers", "group": null, "before": "Cities"})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Countries", "Lakes"]); assert_eq!(members(&s), ["Countries", "Lakes"]); // Out of a group to a place inside another group's run: above that group. s.run("layer.group", &json!({"layer": "Cities", "group": g, "before": "Countries"})).unwrap(); s.run("layer.group", &json!({"layer": "Cities", "group": null, "before": "Lakes"})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Countries", "Lakes"]); // Out of a group, staying where it is, when no place is given. s.run("layer.group", &json!({"layer": "Lakes", "group": null})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Countries", "Lakes"]); // A place in the group must be one of its layers, and not the layer itself. assert!(s.run("layer.group", &json!({"layer": "Rivers", "group": g, "before": "Cities"})).is_err()); assert!(s.run("layer.group", &json!({"layer": "Rivers", "group": null, "before": "Rivers"})).is_err()); assert_eq!(members(&s), ["Countries"]); } #[test] fn a_layer_drops_at_the_end_with_explicit_null_before() { for in_group in [false, true] { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); let g = s.run("group.add", &json!({"name": "Water", "layers": ["Rivers", "Lakes"]})).unwrap()["group"].clone(); let group = if in_group { g } else { Value::Null }; let original = names(&s); // Omitting before keeps the nearest-end behavior used by the group's context menu. s.run("layer.group", &json!({"layer": "Cities", "group": group})).unwrap(); assert_eq!(names(&s), original); let members = s.map().groups[0].children.clone(); let depth = s.undo_depth(); s.run("layer.group", &json!({"layer": "Cities", "group": group, "before": null})).unwrap(); let expected = if in_group { ["Rivers", "Lakes", "Cities", "Countries"] } else { ["Rivers", "Lakes", "Countries", "Cities"] }; assert_eq!(names(&s), expected); assert_eq!(s.undo_depth(), depth + 1); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(names(&s), original); assert_eq!(s.map().groups[0].children, members); s.run("edit.redo", &Value::Null).unwrap(); assert_eq!(names(&s), expected); assert!(s.run("layer.group", &json!({"layer": ["Cities", "Countries"], "group": group, "before": null})).is_err()); } } #[test] fn a_moved_group_never_lands_inside_another() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); s.run("group.add", &json!({"name": "A", "layers": ["Cities"]})).unwrap(); s.run("group.add", &json!({"name": "B", "layers": ["Lakes", "Countries"]})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); // Position 2 among the others (Rivers, Lakes, Countries) is between Lakes and Countries. s.run("group.move", &json!({"group": "A", "to": 2})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Lakes", "Countries"]); } #[test] fn a_layer_leaving_a_group_never_splits_it() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); s.run("group.add", &json!({"name": "A", "layers": ["Rivers", "Lakes", "Countries"]})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); // Out of the middle: above the group. s.run("layer.group", &json!({"layer": "Lakes", "group": null})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); s.run("layer.group", &json!({"layer": "Lakes", "group": "A", "before": "Countries"})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); // Into the group it is in: nothing moves. s.run("layer.group", &json!({"layer": "Lakes", "group": "A"})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); // A bad scale is an error, not a cleared bound. s.run("group.scaleRange", &json!({"group": "A", "min": 1e6, "max": 1e3})).unwrap(); for bad in [json!({"group": "A", "min": -4}), json!({"group": "A", "max": "big"})] { assert!(s.run("group.scaleRange", &bad).is_err()); } assert_eq!(s.map().groups[0].min_scale, Some(1e6)); // Into an empty group, and into a new group: the same. s.run("group.add", &json!({"name": "Empty"})).unwrap(); s.run("layer.group", &json!({"layer": "Lakes", "group": "Empty"})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); s.run("layer.group", &json!({"layer": "Lakes", "group": "A", "before": "Countries"})).unwrap(); s.run("group.add", &json!({"name": "B", "layers": ["Lakes"]})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); } #[test] fn hostile_group_params_never_panic() { let junk = [ json!({"group": 1, "to": 1e30, "before": 99999, "min": "x", "max": -1, "transparency": 1e300}), json!({"group": "A", "to": -1, "before": "A", "layers": [1, 2, 2, "nope"], "visible": 3, "on": "yes"}), json!({"group": "A", "layer": "Cities", "before": "Cities", "transparency": "half", "min": 1e308, "max": 1e-308}), json!({"group": null, "layer": "Rivers", "before": "Lakes", "name": "", "to": 0}), json!({"group": [1], "layer": {"x": 1}, "layers": "Cities", "to": 2.5}), json!({"group": "A", "to": 1e9, "name": "x".repeat(10_000)}), // Nesting (#836): into itself, its subgroup, nothing, junk, before its own layer. json!({"group": "A", "parent": "A"}), json!({"group": "A", "parent": "C", "before": "Rivers"}), json!({"group": "C", "parent": "B", "before": "Countries"}), json!({"group": "B", "parent": 99999, "before": [], "layer": "Lakes"}), json!({"group": "C", "parent": {"x": 1}, "before": "A", "layers": ["Countries"]}), json!({"group": "C", "parent": null, "before": null, "layer": "Countries"}), ]; let ids = [ "group.add", "group.set", "group.expand", "group.scaleRange", "group.move", "group.setParent", "group.indent", "group.outdent", "group.ungroup", "group.remove", "layer.group", "layer.indent", "layer.outdent", "layer.moveUp", "layer.moveDown", ]; for id in ids { for p in &junk { let mut s = world(); s.run("group.add", &json!({"name": "A", "layers": ["Rivers", "Lakes"]})).unwrap(); s.run("group.add", &json!({"name": "B"})).unwrap(); s.run("group.add", &json!({"name": "C", "parent": "A", "layers": ["Lakes"]})).unwrap(); let _ = s.run(id, p); // The groups stay whole: each group's layers, its subgroups' too, sit together in // the drawing order, and no group is in itself. let m = s.map(); for g in &m.groups { let members = m.group_layers(g.id); let at: Vec = m.layers.iter().enumerate().filter(|(_, l)| members.contains(&l.id)).map(|(i, _)| i).collect(); assert!(at.windows(2).all(|w| w[1] == w[0] + 1), "{id} {p}: {at:?}"); assert!(g.children.iter().all(|c| m.layer(*c).is_some()), "{id} {p}"); assert!(g.name.chars().count() <= 200 && (0.0..=100.0).contains(&g.transparency)); assert!(g.parent.is_none_or(|p| p != g.id && m.group(p).is_some()), "{id} {p}"); assert!(m.group_chain(g.id).last().is_some_and(|top| top.parent.is_none()), "{id} {p}: a loop"); } } } } /// Names of the map's layers in drawing order. fn layer_names(s: &Session) -> Vec { s.map().layers.iter().map(|l| l.name.clone()).collect() } fn group_named(s: &Session, name: &str) -> u32 { s.map().groups.iter().find(|g| g.name == name).map(|g| g.id).unwrap() } #[test] fn nested_groups_apply_down_the_chain_and_move_whole() { use rustgis_map::Outline; let mut s = world(); s.run("group.add", &json!({"name": "Base", "layers": ["Rivers", "Lakes", "Countries"]})).unwrap(); s.run("group.add", &json!({"name": "Water", "parent": "Base", "layers": ["Rivers", "Lakes"]})).unwrap(); let (base, water) = (group_named(&s, "Base"), group_named(&s, "Water")); let id = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().id; let (rivers, countries, cities) = (id(&s, "Rivers"), id(&s, "Countries"), id(&s, "Cities")); assert_eq!(s.map().group(water).unwrap().parent, Some(base)); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); assert_eq!(s.map().outline(None), [Outline::Layer(cities), Outline::Group(base)]); assert_eq!(s.map().outline(Some(base)), [Outline::Group(water), Outline::Layer(countries)]); // Hidden, the outer group hides the inner one's layers too; transparency multiplies. s.run("group.set", &json!({"group": "Base", "visible": false})).unwrap(); let m = s.map(); assert!(!m.shows(m.layer(rivers).unwrap()) && m.shows(m.layer(cities).unwrap())); assert_eq!(m.held_by_group(1e6).len(), 3); s.run("group.set", &json!({"group": "Base", "visible": true, "transparency": 50})).unwrap(); s.run("group.set", &json!({"group": "Water", "transparency": 50})).unwrap(); s.run("group.scaleRange", &json!({"group": "Base", "min": 1e6})).unwrap(); let m = s.map(); let (r, c) = (m.layer(rivers).unwrap(), m.layer(countries).unwrap()); assert!((m.layer_opacity(r) - 0.25 * r.opacity()).abs() < 1e-6); assert!((m.layer_opacity(c) - 0.5 * c.opacity()).abs() < 1e-6); assert!(!m.draws_at(r, 2e6) && m.draws_at(r, 5e5)); // Exports fold every level into the layers. let own = r.opacity(); let mut baked = s.map().clone(); baked.bake_groups(); let r = baked.layer(rivers).unwrap(); assert!((r.opacity() - 0.25 * own).abs() < 1e-4 && r.min_scale.is_some_and(|m| m <= 1e6)); // A subgroup moves within its group only, whole. s.run("group.move", &json!({"group": "Water", "to": 0})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); s.run("group.move", &json!({"group": "Water", "to": 99})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Countries", "Rivers", "Lakes"]); // A layer steps over a subgroup as one item, and stops at its group's edge. s.run("layer.moveDown", &json!({"layer": "Countries"})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); assert!(s.run("layer.moveDown", &json!({"layer": "Countries"})).is_err()); // A layer outside steps over the whole outer group. s.run("layer.moveDown", &json!({"layer": "Cities"})).unwrap(); assert_eq!(layer_names(&s), ["Rivers", "Lakes", "Countries", "Cities"]); // Ungrouped, the outer group's layers and subgroups go up one level. s.run("group.ungroup", &json!({"group": "Base"})).unwrap(); assert_eq!(s.map().group(water).unwrap().parent, None); assert!(s.map().group_of(countries).is_none()); // Removing a group takes its subgroups and all their layers. s.run("edit.undo", &Value::Null).unwrap(); s.run("group.remove", &json!({"group": "Base"})).unwrap(); assert_eq!(layer_names(&s), ["Cities"]); assert!(s.map().groups.is_empty()); } #[test] fn outline_commands_indent_and_outdent_one_level_in_one_step() { let mut s = world(); s.run("group.add", &json!({"name": "G", "layers": ["Rivers"]})).unwrap(); let g = group_named(&s, "G"); let group_of = |s: &Session, n: &str| s.map().group_of(s.map().layer_by_name(n).unwrap().id).map(|g| g.id); // Into the group above, as its last item; the drawing order stays. let undo = s.undo_depth(); s.run("layer.indent", &json!({"layer": "Lakes"})).unwrap(); assert_eq!((group_of(&s, "Lakes"), s.undo_depth()), (Some(g), undo + 1)); s.run("layer.indent", &json!({"layer": "Countries"})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); assert_eq!(group_of(&s, "Countries"), Some(g)); // Nothing above, or a layer above: refused, with the reason. assert!(s.run("layer.indent", &json!({"layer": "Cities"})).unwrap_err().contains("first")); assert!(s.run("layer.indent", &json!({"layer": "Lakes"})).unwrap_err().contains("not a group")); // Out, right below its group. s.run("layer.outdent", &json!({"layer": "Lakes"})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Countries", "Lakes"]); assert_eq!(group_of(&s, "Lakes"), None); assert!(s.run("layer.outdent", &json!({"layer": "Lakes"})).is_err()); // A group goes in the group above, and out again just below it. s.run("group.add", &json!({"name": "H", "layers": ["Lakes"]})).unwrap(); let h = group_named(&s, "H"); s.run("group.indent", &json!({"group": "H"})).unwrap(); assert_eq!(s.map().group(h).unwrap().parent, Some(g)); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Countries", "Lakes"]); // A layer in the inner group goes out one level only: into G, just below H. s.run("layer.group", &json!({"layer": "Countries", "group": "H", "before": "Lakes"})).unwrap(); s.run("layer.outdent", &json!({"layer": "Countries"})).unwrap(); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); assert_eq!(group_of(&s, "Countries"), Some(g)); s.run("group.outdent", &json!({"group": "H"})).unwrap(); assert_eq!(s.map().group(h).unwrap().parent, None); assert_eq!(layer_names(&s), ["Cities", "Rivers", "Countries", "Lakes"]); // Undone, a step at a time. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().group(h).unwrap().parent, Some(g)); // No loops, and no deeper than eight levels. assert!(s.run("group.setParent", &json!({"group": "G", "parent": "H"})).is_err()); let mut parent = Value::Null; for level in 1..=rustgis_map::MAX_GROUP_DEPTH { let made = s.run("group.add", &json!({"name": format!("L{level}"), "parent": parent})).unwrap(); parent = made["group"].clone(); } assert!(s.run("group.add", &json!({"name": "Too deep", "parent": parent})).unwrap_err().contains("levels")); assert!(s.run("group.setParent", &json!({"group": "G", "parent": "L7"})).unwrap_err().contains("levels")); s.run("group.setParent", &json!({"group": "G", "parent": "L5", "before": null})).unwrap(); assert_eq!(s.map().group_depth(g), 6); // A group with no layers put at the end of a level goes after the others there. s.run("group.add", &json!({"name": "E1"})).unwrap(); s.run("group.add", &json!({"name": "E2"})).unwrap(); let (e1, e2) = (group_named(&s, "E1"), group_named(&s, "E2")); s.run("group.setParent", &json!({"group": "E1", "parent": null, "before": null})).unwrap(); let top = s.map().outline(None); assert_eq!(top.iter().rev().take(2).collect::>(), [&rustgis_map::Outline::Group(e1), &rustgis_map::Outline::Group(e2)]); } #[test] fn hand_edited_group_parents_open_without_loops_or_too_many_levels() { let mut s = world(); s.run("group.add", &json!({"name": "A", "layers": ["Cities"]})).unwrap(); let mut v: Value = serde_json::from_str(&serde_json::to_string(&s.project).unwrap()).unwrap(); let id = |n: &str| s.map().layer_by_name(n).unwrap().id; // A loop, a parent of its own, a missing parent, a chain ten deep, and a subgroup whose layers // are far from its group's. let mut groups = vec![ json!({"id": 900, "name": "Loop1", "parent": 901, "children": []}), json!({"id": 901, "name": "Loop2", "parent": 900, "children": []}), json!({"id": 902, "name": "Self", "parent": 902, "children": []}), json!({"id": 903, "name": "Orphan", "parent": 12345, "children": []}), json!({"id": 910, "name": "Outer", "children": [id("Cities")]}), json!({"id": 911, "name": "Inner", "parent": 910, "children": [id("Countries")]}), ]; for k in 0..10u32 { groups.push(json!({"id": 920 + k, "name": format!("D{k}"), "parent": if k == 0 { Value::Null } else { json!(919 + k) }})); } v["maps"][0]["groups"] = json!(groups); let p = Project::from_json(&v.to_string()).unwrap(); let m = p.maps.first().unwrap(); for g in &m.groups { assert!(m.group_depth(g.id) <= rustgis_map::MAX_GROUP_DEPTH, "{}", g.name); assert!(m.group_chain(g.id).last().is_some_and(|top| top.parent.is_none()), "{}", g.name); } let parent = |n: &str| m.groups.iter().find(|g| g.name == n).and_then(|g| g.parent); assert_eq!((parent("Self"), parent("Orphan"), parent("Inner")), (None, None, Some(910))); assert!(parent("Loop1").is_none() || parent("Loop2").is_none()); assert_eq!(parent("D7"), Some(926)); assert_eq!(parent("D8"), None); // The subgroup's layer gathered under its group's. let order: Vec<&str> = m.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(order, ["Cities", "Countries", "Rivers", "Lakes"]); // Saved, a group at the top level writes no parent, so older versions read the same file. let text = serde_json::to_string(m.groups.iter().find(|g| g.name == "Outer").unwrap()).unwrap(); assert!(!text.contains("parent")); } #[test] fn a_copied_group_pastes_with_its_subgroups() { let mut s = world(); s.run("group.add", &json!({"name": "Base", "layers": ["Rivers", "Lakes", "Countries"]})).unwrap(); s.run("group.add", &json!({"name": "Water", "parent": "Base", "layers": ["Rivers", "Lakes"]})).unwrap(); s.run("group.add", &json!({"name": "Target", "layers": ["Cities"]})).unwrap(); s.run("layer.copy", &json!({"group": "Base"})).unwrap(); s.run("layer.paste", &json!({"group": "Target"})).unwrap(); let m = s.map(); let target = m.groups.iter().find(|g| g.name == "Target").unwrap().id; let base = m.groups.iter().find(|g| g.name != "Base" && g.name.starts_with("Base")).unwrap(); let water = m.groups.iter().find(|g| g.name != "Water" && g.name.starts_with("Water")).unwrap(); assert_eq!((base.parent, water.parent), (Some(target), Some(base.id))); assert_eq!(m.group_layers(water.id).len(), 2); assert_eq!(m.group_layers(target).len(), 4); } /// Large elevation models come as tiles in a projected coordinate system (#155): mosaic them on /// their own grid, clip by a polygon layer, share one stretch, and say when a raster is an overview. #[test] fn dem_tiles_mosaic_clip_by_polygon_share_a_stretch_and_report_overviews() { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let utm = Crs::from_epsg(25832).unwrap(); // 10 × 10 tiles of 100 m cells: values 100 + column in the west, 200 + column in the east. // Each tile has one no-data cell, `hole` cells from its top-left. let tile = |name: &str, x0: f64, base: f32, cell: f64, hole: usize| { let n = (1000.0 / cell) as u32; let mut z: Vec = (0..n * n).map(|i| base + (i % n) as f32).collect(); if let Some(v) = z.get_mut(hole) { *v = -9999.0; } let gt = GeoTransform { origin_x: x0, origin_y: 5_700_000.0, pixel_width: cell, pixel_height: -cell }; Raster::new(name, n, n, vec![z], gt, utm, Some(-9999.0), ColorHint::Gray, "Float32").unwrap() }; let mut s = world(); for (name, r) in [ ("west", tile("west", 360_000.0, 100.0, 100.0, 99)), ("east", tile("east", 361_000.0, 200.0, 100.0, 99)), ("over", tile("over", 360_000.0, 900.0, 100.0, 0)), ("coarse", tile("coarse", 362_000.0, 0.0, 200.0, 0)), // Half a cell off the others' grid. ("shifted", tile("shifted", 362_050.0, 0.0, 100.0, 0)), ] { let data = base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()); s.run("layer.addDataBytes", &json!({"fileName": format!("{name}.tif"), "data": data})).unwrap(); } // Tiny cells far apart: more columns between them than a raster can have. for (name, x0) in [("near", 0.0), ("far", 100.0)] { let gt = GeoTransform { origin_x: x0, origin_y: 1e-6, pixel_width: 1e-9, pixel_height: -1e-9 }; let r = Raster::new(name, 10, 10, vec![vec![1.0; 100]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); } let raster = |s: &Session, v: &Value| s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); // Mosaic: one grid in the tiles' own CRS and cell size; the first input wins where they overlap. let dir = std::env::temp_dir().join(format!("rustgis-mosaic-{}", std::process::id())); std::fs::remove_dir_all(&dir).ok(); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("dem.tif").display().to_string(); let v = s.run("raster.mosaic", &json!({"inputs": ["west", "over", "east"], "name": "dem", "path": path})).unwrap(); assert_eq!(v["path"], json!(path)); let m = raster(&s, &v); assert_eq!((m.width, m.height, m.crs, m.cell_size()), (20, 10, utm, (100.0, 100.0))); assert_eq!((m.transform.origin_x, m.transform.origin_y), (360_000.0, 5_700_000.0)); assert_eq!((m.value(0, 3, 0), m.value(0, 15, 2)), (Some(103.0), Some(205.0))); // West's no-data corner is filled from the overlapping tile; east's stays no-data. assert_eq!((m.value(0, 0, 0), m.value(0, 9, 9), m.value(0, 19, 9)), (Some(100.0), Some(909.0), None)); let written = rustgis_data::raster::read_path(std::path::Path::new(&path)).unwrap(); assert_eq!((written.width, written.height, written.crs), (20, 10, utm)); // An input naming its band otherwise than the first is said; the mosaic keeps the first's. let over = s.map().layer_by_name("over").unwrap().id; let named = |s: &mut Session, band: &str| { let data = s.map_mut().layer_mut(over).unwrap().raster.as_mut().unwrap().data.as_mut().unwrap(); std::sync::Arc::make_mut(data).bands[0].name = band.into(); }; let band_of = |s: &Session, layer: &str| { s.map().layer_by_name(layer).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].name.clone() }; let (west_band, over_band) = (band_of(&s, "west"), band_of(&s, "over")); named(&mut s, "Altitude"); let mark = s.notice_mark(); let v = s.run("raster.mosaic", &json!({"inputs": ["west", "over"], "name": "renamed"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n == "over: band names, classes or wavelengths other than west's, which the mosaic keeps"), "{said:?}"); assert_eq!(raster(&s, &v).bands[0].name, west_band); let mark = s.notice_mark(); s.run("raster.mosaic", &json!({"inputs": ["west", "east"], "name": "same"})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("band names, classes"))); named(&mut s, &over_band); // So is one giving its band another wavelength; the mosaic keeps the first's. let wavelength = |s: &mut Session, nm: Option| { let data = s.map_mut().layer_mut(over).unwrap().raster.as_mut().unwrap().data.as_mut().unwrap(); std::sync::Arc::make_mut(data).bands[0].wavelength = nm; }; wavelength(&mut s, Some(842.0)); let mark = s.notice_mark(); let v = s.run("raster.mosaic", &json!({"inputs": ["west", "over"], "name": "nir"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n == "over: band names, classes or wavelengths other than west's, which the mosaic keeps"), "{said:?}"); assert_eq!(raster(&s, &v).bands[0].wavelength, None); // Inputs of one wavelength: the mosaic has it, and nothing is said. let west = s.map().layer_by_name("west").unwrap().id; let data = s.map_mut().layer_mut(west).unwrap().raster.as_mut().unwrap().data.as_mut().unwrap(); std::sync::Arc::make_mut(data).bands[0].wavelength = Some(842.0); let mark = s.notice_mark(); let v = s.run("raster.mosaic", &json!({"inputs": ["west", "over"], "name": "nir both"})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("band names, classes")), "{:?}", s.notices_since(mark)); assert_eq!(raster(&s, &v).bands[0].wavelength, Some(842.0)); let data = s.map_mut().layer_mut(west).unwrap().raster.as_mut().unwrap().data.as_mut().unwrap(); std::sync::Arc::make_mut(data).bands[0].wavelength = None; wavelength(&mut s, None); // A file given by path says what its reading left out (here a sidecar's scale); one read whole // says nothing. let file = dir.join("west.tif"); std::fs::write(&file, geotiff::write(&tile("west", 360_000.0, 100.0, 100.0, 99), 0).unwrap()).unwrap(); let mark = s.notice_mark(); s.run("raster.mosaic", &json!({"inputs": [file.display().to_string(), "east"], "name": "whole"})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("not applied")), "{:?}", s.notices_since(mark)); std::fs::write(dir.join("west.tif.aux.xml"), r#"5"#) .unwrap(); let mark = s.notice_mark(); s.run("raster.mosaic", &json!({"inputs": [file.display().to_string(), "east"], "name": "offset"})).unwrap(); let said = format!("{}: its .aux.xml sidecar: 1 scale or offset(s) not applied: the values are as stored", file.display()); assert!(s.notices_since(mark).iter().any(|n| n.text == said), "{:?}", s.notices_since(mark)); std::fs::remove_dir_all(&dir).ok(); // Bare file names read files too: tasks and notebooks from elsewhere may not run it. assert!(crate::cmd::items::touches_files(&s, "raster.mosaic", &json!({"inputs": ["a.tif", "b.tif"]}))); for (bad, why) in [ (json!({"inputs": ["west"]}), "two or more"), (json!({"inputs": ["west", "coarse"]}), "cell size"), (json!({"inputs": ["west", "shifted"]}), "line up"), (json!({"inputs": ["near", "far"]}), "too far"), (json!({"inputs": ["west", "Countries"]}), "no raster layer"), (json!({"inputs": ["west", 3]}), "file path"), ] { let e = s.run("raster.mosaic", &bad).unwrap_err().to_string(); assert!(e.contains(why), "{bad}: {e}"); } // Clip by a polygon layer: the window around it, cells outside it no-data. let tr = m.transformer(); let ll = |x: f64, y: f64| { let c = tr.to_lonlat(rustgis_geom::Coord::new(x, y)).unwrap(); json!([c.x, c.y]) }; s.run("layer.template", &json!({"template": "polygons", "name": "City"})).unwrap(); // A triangle, so the mask isn't just the window. let ring = [ll(360_500.0, 5_700_000.0), ll(361_500.0, 5_700_000.0), ll(361_500.0, 5_699_500.0), ll(360_500.0, 5_700_000.0)]; s.run("edit.addFeature", &json!({"layer": "City", "geometry": {"type": "Polygon", "coordinates": [ring]}})).unwrap(); let v = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City"})).unwrap(); let c = raster(&s, &v); // The diagonal runs from column 5 at row 0 to column 15 at row 5: rows of 9, 7, 5, 3 and 1 // cell centres inside, in a window trimmed to columns 6–14. assert_eq!((c.width, c.height, c.crs), (9, 5, utm)); assert_eq!(c.bands[0].stats.count, 25); assert_eq!((c.value(0, 8, 0), c.value(0, 0, 0), c.value(0, 0, 4)), (Some(204.0), Some(106.0), None)); s.run("layer.template", &json!({"template": "polygons", "name": "Elsewhere"})).unwrap(); let far = json!([[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]); s.run("edit.addFeature", &json!({"layer": "Elsewhere", "geometry": {"type": "Polygon", "coordinates": far}})).unwrap(); let e = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "Elsewhere"})).unwrap_err(); assert!(e.to_string().contains("no cell"), "{e}"); assert!(s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "west"})).is_err()); // selectedOnly: true with no selected features fails with a descriptive error. s.map_mut().layers.iter_mut().find(|l| l.name == "City").unwrap().selection.clear(); let e_sel_none = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": true})).unwrap_err(); assert!(e_sel_none.to_string().contains("no features are selected"), "{e_sel_none}"); // Add a second feature to City further east in dem's extent. let ring2 = [ll(361_500.0, 5_700_000.0), ll(362_000.0, 5_700_000.0), ll(362_000.0, 5_699_000.0), ll(361_500.0, 5_700_000.0)]; s.run("edit.addFeature", &json!({"layer": "City", "geometry": {"type": "Polygon", "coordinates": [ring2]}})).unwrap(); assert_eq!(s.map().layer_by_name("City").unwrap().dataset.features.len(), 2); // With feature 0 selected: selectedOnly: true clips only feature 0. s.map_mut().layers.iter_mut().find(|l| l.name == "City").unwrap().selection = vec![0]; let v_sel = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": true})).unwrap(); let c_sel = raster(&s, &v_sel); assert_eq!(c_sel.bands[0].stats.count, 25); // selectedOnly: false clips all features even with selection active. let v_all = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": false})).unwrap(); let c_all = raster(&s, &v_all); assert!(c_all.bands[0].stats.count > 25); // An empty polygon layer fails validation. s.run("layer.template", &json!({"template": "polygons", "name": "EmptyPoly"})).unwrap(); let e_empty = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "EmptyPoly"})).unwrap_err(); assert!(e_empty.to_string().contains("has no features"), "{e_empty}"); // A polygon layer with UTM source metadata still stores lon/lat geometry. let mut proj_ds = rustgis_data::Dataset::new("CityProj", rustgis_data::GeometryType::Polygon, vec![]); proj_ds.crs = Some(utm); let utm_ring = [ rustgis_geom::Coord::new(360_500.0, 5_700_000.0), rustgis_geom::Coord::new(361_500.0, 5_700_000.0), rustgis_geom::Coord::new(361_500.0, 5_699_500.0), rustgis_geom::Coord::new(360_500.0, 5_700_000.0), ]; let lonlat_ring = utm_ring.into_iter().map(|c| tr.to_lonlat(c).unwrap()).collect(); proj_ds.push(Some(rustgis_data::Geometry::Polygon(vec![vec![lonlat_ring]])), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, proj_ds)); let v_proj = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "CityProj"})).unwrap(); let c_proj = raster(&s, &v_proj); assert_eq!((c_proj.width, c_proj.height, c_proj.crs), (9, 5, utm)); assert_eq!(c_proj.bands[0].stats.count, 25); // One stretch shared across tiles, as fixed bounds. s.run("raster.stretch", &json!({"layer": "west", "type": "custom", "min": 100, "max": 300})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); let st = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().raster.as_ref().unwrap().style.stretch; assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(100.0, 300.0)); s.run("raster.stretch", &json!({"layer": "west", "type": "minMax"})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(100.0, 109.0)); // A mask on the source narrows its range, and the shared one with it. s.run("raster.mask", &json!({"layer": "west", "values": [100]})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(101.0, 109.0)); assert!(s.run("raster.stretch", &json!({"layer": "east", "like": "Countries"})).is_err()); // A raster opened from an overview says so in its results, not only in a notice. let mut ov = tile("big", 360_000.0, 100.0, 100.0, 99); ov.overview = Some((20, 20)); let bytes = geotiff::write(&ov, 0).unwrap(); let v = crate::cmd::raster::add_raster(&mut s, "big", rustgis_map::RasterLayer::from_bytes(ov, "big.tif", bytes)).unwrap(); assert_eq!(v["overviewOf"], json!([20, 20])); let out = std::env::temp_dir().join(format!("rustgis-overview-{}.tif", std::process::id())); std::fs::remove_file(&out).ok(); let v = s.run("share.exportRaster", &json!({"layer": "big", "path": out.display().to_string()})).unwrap(); assert_eq!(v["overviewOf"], json!([20, 20])); std::fs::remove_file(&out).ok(); let e = s.run("raster.mosaic", &json!({"inputs": ["big", "east"]})).unwrap_err(); assert!(e.to_string().contains("overview"), "{e}"); } /// A graph passed to a model command says what reading it left out (one notice per kind, with a /// count); a clean graph says nothing. #[test] fn model_graph_drops_are_noticed() { let mut s = world(); let clean = json!({ "nodes": [ {"id": "i1", "kind": "input", "x": 0, "y": 0, "layer": "Cities"}, {"id": "o1", "kind": "output", "x": 240, "y": 0, "name": "Kept"} ], "edges": [{"from": "i1", "fromPort": "out", "to": "o1", "toPort": "in"}] }); let said = |s: &Session, mark: u64, what: &str| s.notices_since(mark).iter().filter(|n| n.text.contains(what)).count(); let mark = s.notice_mark(); s.run("model.new", &json!({"name": "Clean", "graph": clean})).unwrap(); s.run("model.save", &json!({"name": "Clean", "graph": clean})).unwrap(); s.run("model.validate", &json!({"graph": clean})).unwrap(); assert_eq!(said(&s, mark, "model graph"), 0, "{:?}", s.notices_since(mark)); let lossy = json!({ "nodes": [ {"id": "i1", "kind": "input", "x": 0, "y": 0, "layer": "Cities"}, {"id": "i1", "kind": "input", "x": 10, "y": 0, "layer": "Countries"}, {"id": " ", "kind": "input", "x": 20, "y": 0, "layer": "Cities"}, {"kind": 7}, {"id": "o1", "kind": "output", "x": 240, "y": 0, "name": "Kept"}, {"id": " i2 ", "kind": "input", "x": 5e7, "y": 0, "layer": "Cities", "params": "not an object"}, // Repeats " i2 " once trimmed: left out, and so is the connection naming it. {"id": "i2", "kind": "input", "x": 30, "y": 0, "layer": "Countries"} ], "edges": [ {"from": "i1", "fromPort": "out", "to": "o1", "toPort": "in"}, {"from": "i1", "fromPort": "out", "to": "missing", "toPort": "in"}, {"from": "o1", "fromPort": "out", "to": "o1", "toPort": "in"}, {"from": "i1", "fromPort": "out", "to": "o1", "toPort": ""}, // Names the node by the id it was given: it follows the trimmed id, so it is kept. {"from": " i2 ", "fromPort": "out", "to": "o1", "toPort": "in"}, {"from": "i2", "fromPort": "out", "to": "o1", "toPort": "in"}, "not an edge" ] }); for (cmd, params) in [ ("model.new", json!({"name": "Lossy", "graph": lossy})), ("model.save", json!({"name": "Lossy", "graph": lossy})), ("model.validate", json!({"graph": lossy})), ("model.export", json!({"graph": lossy})), ] { let mark = s.notice_mark(); s.run(cmd, ¶ms).unwrap(); assert_eq!(said(&s, mark, "4 node(s) left out"), 1, "{cmd}: {:?}", s.notices_since(mark)); assert_eq!(said(&s, mark, "5 connection(s) left out"), 1, "{cmd}: {:?}", s.notices_since(mark)); assert_eq!(said(&s, mark, "1 node id(s) changed"), 1, "{cmd}: {:?}", s.notices_since(mark)); assert_eq!(said(&s, mark, "1 node position(s) changed"), 1, "{cmd}: {:?}", s.notices_since(mark)); assert_eq!(said(&s, mark, "1 node(s) had parameters"), 1, "{cmd}: {:?}", s.notices_since(mark)); } // A command that fails says nothing about the graph it was passed. let mark = s.notice_mark(); assert!(s.run("model.save", &json!({"name": "Lossy", "rename": "Clean", "graph": lossy})).is_err()); // A path under a regular file can't be written. let blocker = std::env::temp_dir().join(format!("rustgis-model-export-{}", std::process::id())); std::fs::write(&blocker, b"not a folder").unwrap(); let unwritable = blocker.join("lossy.model.json"); assert!(s.run("model.export", &json!({"graph": lossy, "path": unwritable.to_string_lossy()})).is_err()); let _ = std::fs::remove_file(&blocker); assert_eq!(said(&s, mark, "model graph"), 0, "{:?}", s.notices_since(mark)); let saved = s.project.models.iter().find(|m| m.name == "Lossy").and_then(|m| m.graph.as_ref()).map(|g| g.edges.len()); assert_eq!(saved, Some(2), "the connection naming \" i2 \" follows its trimmed id"); } /// ModelBuilder graphs: validated, run in flow order, intermediate results dropped, outputs named. fn model_graphs(s: &mut Session) { let graph = json!({ "nodes": [ {"id": "i1", "kind": "input", "x": 0, "y": 0, "layer": "Cities"}, {"id": "t1", "kind": "tool", "x": 240, "y": 0, "tool": "buffer", "params": {"distance": 20}}, {"id": "t2", "kind": "tool", "x": 480, "y": 0, "tool": "dissolve"}, {"id": "o1", "kind": "output", "x": 720, "y": 0, "name": "City zones"} ], "edges": [ {"from": "i1", "fromPort": "out", "to": "t1", "toPort": "input"}, {"from": "t1", "fromPort": "out", "to": "t2", "toPort": "input"}, {"from": "t2", "fromPort": "out", "to": "o1", "toPort": "in"} ] }); s.run("model.save", &json!({"name": "Zones", "graph": graph})).unwrap(); let m = s.project.models.iter().find(|m| m.name == "Zones").unwrap(); assert_eq!(m.steps.len(), 2, "steps follow the graph"); assert_eq!(m.steps[1].params["input"], "%1"); assert_eq!(s.run("model.validate", &json!({"name": "Zones"})).unwrap()["ok"], true); let layers = s.map().layers.len(); let r = s.run("model.run", &json!({"name": "Zones"})).unwrap(); assert_eq!(r["ran"].as_array().unwrap().len(), 2); assert_eq!(r["outputs"], json!(["City zones"])); assert!(s.map().layer_by_name("City zones").is_some()); assert_eq!(s.map().layers.len(), layers + 1, "the buffer, only feeding the dissolve, is removed"); let report = s.model_run.clone().unwrap(); assert!(report.ok && report.nodes.iter().all(|n| n.status == "done")); // Run again: the output name stays unique. let r = s.run("model.run", &json!({"name": "Zones"})).unwrap(); assert_eq!(r["outputs"], json!(["City zones 2"])); // A result kept by an output and fed on to another tool: the tool reads it by its new name, // whichever runs first. let both = json!({"nodes": [ {"id": "i1", "kind": "input", "x": 0, "y": 0, "layer": "Cities"}, {"id": "t1", "kind": "tool", "x": 200, "y": 0, "tool": "buffer", "params": {"distance": 20}}, {"id": "o1", "kind": "output", "x": 300, "y": 0, "name": "Buffers"}, {"id": "t2", "kind": "tool", "x": 400, "y": 0, "tool": "dissolve"}], "edges": [{"from": "i1", "fromPort": "out", "to": "t1", "toPort": "input"}, {"from": "t1", "fromPort": "out", "to": "o1", "toPort": "in"}, {"from": "t1", "fromPort": "out", "to": "t2", "toPort": "input"}]}); let r = s.run("model.run", &json!({"graph": both})).unwrap(); assert_eq!(r["outputs"][0], "Buffers"); // Saving over a model from a file keeps it one. s.run("model.save", &json!({"name": "From disk", "graph": both, "imported": true})).unwrap(); assert!(!s.item_trusted("model", "from disk")); s.run("model.save", &json!({"name": "From disk", "graph": both, "rename": "Renamed"})).unwrap(); assert!(!s.item_trusted("model", "renamed") && !s.item_trusted("model", "from disk")); s.run("model.remove", &json!({"name": "Renamed"})).unwrap(); // Problems: a missing layer, an unconnected required input, a loop. let mut bad = graph.clone(); bad["nodes"][0]["layer"] = json!("Nowhere"); bad["edges"].as_array_mut().unwrap().push(json!({"from": "t2", "fromPort": "out", "to": "t1", "toPort": "input"})); let v = s.run("model.validate", &json!({"graph": bad})).unwrap(); let codes: Vec<&str> = v["problems"].as_array().unwrap().iter().map(|p| p["code"].as_str().unwrap()).collect(); assert!(codes.contains(&"missing-layer") && codes.contains(&"duplicate-input") && codes.contains(&"cycle"), "{codes:?}"); let e = s.run("model.run", &json!({"graph": bad})).unwrap_err(); assert!(!e.is_empty()); assert!(!s.model_run.as_ref().unwrap().ok); // A failing tool stops the run, names its node and leaves the project as it was. let layers = s.map().layers.len(); let fails = json!({"nodes": [ {"id": "i1", "kind": "input", "layer": "Cities"}, {"id": "t1", "kind": "tool", "tool": "buffer", "params": {"distance": 20}}, {"id": "t2", "kind": "tool", "tool": "dissolve", "params": {"field": "no_such_field"}}], "edges": [{"from": "i1", "fromPort": "out", "to": "t1", "toPort": "input"}, {"from": "t1", "fromPort": "out", "to": "t2", "toPort": "input"}]}); s.run("model.run", &json!({"graph": fails})).unwrap_err(); let rep = s.model_run.clone().unwrap(); assert_eq!(rep.nodes.last().unwrap().status, "error"); assert_eq!(s.map().layers.len(), layers, "a failed run is undone"); // Two outputs on one result are refused. let mut twice = graph.clone(); twice["nodes"].as_array_mut().unwrap().push(json!({"id": "o2", "kind": "output", "name": "Again"})); twice["edges"].as_array_mut().unwrap().push(json!({"from": "t2", "fromPort": "out", "to": "o2", "toPort": "in"})); let v = s.run("model.validate", &json!({"graph": twice})).unwrap(); assert!(v["problems"].as_array().unwrap().iter().any(|p| p["code"] == "shared-output")); // An unsaved graph exports as passed. let t = s.run("model.export", &json!({"name": "Unsaved", "graph": twice})).unwrap(); assert!(t["text"].as_str().unwrap().contains("Again")); // Hostile graphs: repeated ids, unknown kinds and ports, edges to nowhere, huge positions. for junk in [ json!({"nodes": [{"id": "a", "kind": "tool", "tool": "buffer", "x": 1e308}, {"id": "a", "kind": "weird"}, {"id": "b", "kind": "output"}, {"id": "", "kind": "input"}, 7], "edges": [{"from": "a", "fromPort": "zz", "to": "b", "toPort": "nope"}, {"from": "b", "to": "a", "toPort": "input"}, {"from": "a", "to": "a", "toPort": "input"}, {"from": "q", "to": "a", "toPort": "input"}, "x"]}), json!({"nodes": "x"}), json!({"nodes": [{"id": "t", "kind": "tool", "tool": "buffer", "params": [1, 2]}], "edges": {}}), ] { let _ = s.run("model.validate", &json!({"graph": junk})); let _ = s.run("model.run", &json!({"graph": junk})); let _ = s.run("model.export", &json!({"graph": junk})); let _ = s.run("model.save", &json!({"name": "Junk", "graph": junk})); } let _ = s.run("model.remove", &json!({"name": "Junk"})); // A graph without positions is laid out along the flow. let g = rustgis_engine_graph(&fails); assert!(g.nodes[0].x < g.nodes[1].x && g.nodes[1].x < g.nodes[2].x); // Models saved as steps still run as steps: `%n` anywhere, and a clear error for one too far. s.run( "model.new", &json!({"name": "Steps", "steps": [ {"tool": "buffer", "params": {"input": "Cities", "distance": 5}}, {"tool": "dissolve", "params": {"input": "%3"}}]}), ) .unwrap(); assert!(s.project.models.iter().any(|m| m.name == "Steps" && m.graph.is_none())); let e = s.run("model.run", &json!({"name": "Steps"})).unwrap_err(); assert!(e.contains("uses %3"), "{e}"); s.run("model.remove", &json!({"name": "Steps"})).unwrap(); // Old step models open as graphs. let steps = vec![ crate::project::ModelStep { tool: "buffer".into(), params: json!({"input": "Cities", "distance": 5}) }, crate::project::ModelStep { tool: "dissolve".into(), params: json!({"input": "%1"}) }, ]; let g = crate::model::steps_to_graph(&steps); assert_eq!(g.nodes.len(), 3); assert_eq!(g.edges.len(), 2); assert_eq!(crate::model::graph_to_steps(&g), steps); // Export and import as a model file; an imported model may not touch files. let text = s.run("model.export", &json!({"name": "Zones"})).unwrap()["text"].as_str().unwrap().to_string(); assert!(text.contains(crate::model::SCHEMA)); let m = s.run("model.import", &json!({"text": text})).unwrap(); let imported = m["model"].as_str().unwrap().to_string(); assert_ne!(imported, "Zones"); assert!(s.project.models.iter().any(|m| m.name == imported && m.graph.as_ref().is_some_and(|g| g.nodes.len() == 4))); let mut writes = graph.clone(); writes["nodes"][1]["params"]["output"] = json!("/tmp/zones.shp"); s.run("model.new", &json!({"name": "Writes", "graph": writes, "imported": true})).unwrap(); let e = s.run("model.run", &json!({"name": "Writes"})).unwrap_err(); assert!(e.contains("from a file"), "{e}"); for n in ["Zones", "Writes", imported.as_str()] { s.run("model.remove", &json!({"name": n})).unwrap(); } } /// A model from a file, saved as steps, has each step checked with its tool: a tool that writes a /// file without naming it (vectorToPmtiles with no `output`) is refused, and a built-in tool's /// step that only names its output layer runs (#1043). #[test] fn a_model_from_a_file_checks_each_step_with_its_tool() { let mut s = Session::new(sample::world_project()); s.queue_gp_files = true; let export = json!([{"tool": "vectorToPmtiles", "params": {"input": "Cities", "minZoom": 0, "maxZoom": 0}}]); s.run("model.new", &json!({"name": "Export", "steps": export, "imported": true})).unwrap(); let e = s.run("model.run", &json!({"name": "Export"})).unwrap_err(); assert!(e.contains("step 1 (vectorToPmtiles)") && e.contains("from a file"), "{e}"); assert!(s.take_gp_files().is_empty(), "nothing was exported"); // Made here, the same model runs. s.run("model.new", &json!({"name": "Export here", "steps": export})).unwrap(); s.run("model.run", &json!({"name": "Export here"})).unwrap(); assert_eq!(s.take_gp_files().len(), 1); let named = json!([{"tool": "buffer", "params": {"input": "Cities", "distance": 100, "output": "Near cities"}}]); s.run("model.new", &json!({"name": "Buffer", "steps": named, "imported": true})).unwrap(); s.run("model.run", &json!({"name": "Buffer"})).unwrap(); assert!(s.map().layer_by_name("Near cities").is_some()); // A path through the output stays refused. let path = json!([{"tool": "buffer", "params": {"input": "Cities", "distance": 100, "output": "/tmp/near.shp"}}]); s.run("model.new", &json!({"name": "Buffer to a file", "steps": path, "imported": true})).unwrap(); let e = s.run("model.run", &json!({"name": "Buffer to a file"})).unwrap_err(); assert!(e.contains("from a file"), "{e}"); } fn rustgis_engine_graph(v: &Value) -> crate::project::ModelGraph { crate::model::graph_from_json(v).unwrap() } /// Project info holds everything an agent needs about the open project in one call: the view's /// extent, the active layer, each layer's group, source, extent, definition query and labels, /// and the layouts with their map frames; sources lose their passwords, and the options filter /// layers by name and leave fields out. #[test] fn project_info_describes_the_whole_project() { let mut s = world(); s.view_size = (1000.0, 600.0); s.run("layer.definitionQuery", &json!({"layer": "Cities", "where": "pop_max > 1000000"})).unwrap(); s.run("layout.new", &json!({"name": "Wall map"})).unwrap(); if let Some(l) = s.map_mut().layers.iter_mut().find(|l| l.name == "Rivers") { l.dataset.source = Some("postgresql://mapper:hunter2@db.example.com/gis".into()); } let info = s.run("info.project", &json!({})).unwrap(); assert_eq!(info["unsaved"], true, "{info}"); let e = info["extent"].as_array().unwrap(); assert!(e.len() == 4 && e[0].as_f64() < e[2].as_f64() && e[1].as_f64() < e[3].as_f64(), "{info}"); assert_eq!(info["activeLayer"]["name"], "Countries", "{info}"); let layer = |name: &str| info["layers"].as_array().unwrap().iter().find(|l| l["name"] == name).cloned().unwrap(); assert_eq!(layer("Cities")["definitionQuery"], "pop_max > 1000000"); assert_eq!(layer("Countries")["labels"], "name"); assert_eq!(layer("Countries")["extent"][0], -180.0); assert!(layer("Countries")["fields"].as_array().is_some_and(|f| !f.is_empty())); let source = layer("Rivers")["source"].as_str().unwrap().to_string(); assert!(source.contains("db.example.com") && !source.contains("hunter2"), "{source}"); let layouts = info["layouts"].as_array().unwrap(); assert_eq!(layouts.len(), 1, "{info}"); assert_eq!(layouts[0]["name"], "Wall map"); assert_eq!(layouts[0]["mapFrames"][0]["map"], "World", "{info}"); let page = s.project.layouts.first().map(|l| (l.width, l.height)).unwrap(); assert_eq!((&layouts[0]["width"], &layouts[0]["height"]), (&json!(page.0), &json!(page.1)), "{info}"); assert!(page.0 > 0.0 && page.1 > 0.0); if let Some(l) = s.map_mut().layers.iter_mut().find(|l| l.name == "Cities") { l.transparency = 25.0; } let again = s.run("info.project", &json!({})).unwrap(); let cities = again["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Cities").cloned().unwrap(); assert_eq!(cities["transparency"], 25.0, "{again}"); let some = s.run("info.project", &json!({"nameFilter": "CIT", "includeFields": false})).unwrap(); let layers = some["layers"].as_array().unwrap(); assert_eq!(layers.len(), 1, "{some}"); assert_eq!(layers[0]["name"], "Cities"); assert!(layers[0].get("fields").is_none(), "{some}"); } /// A script refuses scripts, notebooks and `ui.*` commands, by the one rule the Script pane marks /// its lines with (#653). #[test] fn scripts_refuse_scripts_notebooks_and_ui_commands() { use crate::cmd::workflows::{refused_in_script, script_commands}; for id in ["script.run", "ui.pane.labeling", "notebook.run"] { assert!(refused_in_script(id), "{id}"); let e = script_commands(&format!("map.zoomFull\n{id} {{}}")).unwrap_err(); assert!(e.contains("line 2") && e.contains("can't run in a script"), "{e}"); } assert!(!refused_in_script("map.zoomFull") && !refused_in_script("notebook.new")); } /// A script can't edit a layer read in place from its file, as a command run on its own can't: /// a later line loading the layer would otherwise lose the edit without a word. #[test] fn scripts_refuse_edits_to_streamed_layers() { let path = grid_fgb("script-streamed", 20); let mut s = with_streamed(&path); let before = s.map().layer_by_name("Streamed").map(|l| l.dataset.features.len()); let script = "edit.enable {\"on\": true}\nedit.addFeature {\"layer\": \"Streamed\", \"geometry\": {\"type\": \"Point\", \"coordinates\": [1, 2]}}\nlayer.loadAll {\"layer\": \"Streamed\"}"; let e = s.run("script.run", &json!({"script": script})).unwrap_err(); assert!(e.starts_with("line 2:") && e.contains("read in place"), "{e}"); let l = s.map().layer_by_name("Streamed").unwrap(); assert!(l.is_streamed(), "the failed script kept nothing"); assert_eq!(Some(l.dataset.features.len()), before); let _ = std::fs::remove_file(path); } /// A map on Mars (#223): the body switcher puts a Mars basemap under it, its measurements and /// scale are Mars', and the project keeps the body; back on the Earth the Mars basemap goes. #[test] fn celestial_bodies_set_the_basemap_and_the_measurements() { let mut s = Session::new(Project::default()); let earth = |s: &mut Session| { s.run("raster.measure", &json!({"kind": "distance", "points": [[0, 0], [90, 0]]})).unwrap()["metres"].as_f64().unwrap() }; let on_earth = earth(&mut s); let earth_scale = s.map().scale(); s.run("map.addTiles", &json!({"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "id": "osm"})).unwrap(); let v = s.run("map.body", &json!({"name": "Mars"})).unwrap(); assert_eq!(v["body"], "mars"); assert_eq!(s.map().body().id, "mars"); // Mars' basemap replaces the Earth's. let tiles = s.run("map.tiles", &Value::Null).unwrap(); let ids: Vec<&str> = tiles.as_array().unwrap().iter().filter_map(|t| t["id"].as_str()).collect(); assert_eq!(ids, ["planet-mars-viking-mdim21"]); assert!(s.map().tiles.first().is_some_and(|t| t.tms)); // The built-in basemap's Earth land isn't drawn on Mars: a plain background under its tiles. let (background, land, ..) = s.map().palette(); assert_eq!(background, land); // A quarter of Mars' equator, and the scale of a smaller world. let on_mars = earth(&mut s); let k = rustgis_geom::Body::get("mars").ratio(); assert!((on_mars / on_earth - k).abs() < 1e-9 && (on_mars - std::f64::consts::FRAC_PI_2 * 3_389_526.7).abs() < 1.0, "{on_mars}"); assert!((s.map().scale() / earth_scale - k).abs() < 1e-9); let area = s.run("raster.measure", &json!({"kind": "area", "points": [[0, 0], [1, 0], [1, 1]]})).unwrap(); // Leaving Mars without a new basemap still takes Mars' off. s.run("map.body", &json!({"name": "earth", "basemap": false})).unwrap(); assert!(s.map().tiles.is_empty()); let earth_area = s.run("raster.measure", &json!({"kind": "area", "points": [[0, 0], [1, 0], [1, 1]]})).unwrap(); let ratio = area["squareMetres"].as_f64().unwrap() / earth_area["squareMetres"].as_f64().unwrap(); assert!((ratio - k * k).abs() < 1e-9, "{ratio}"); // Another of Mars' basemaps, named alone; saved and opened again, the map is still on Mars. s.run("map.body", &json!({"basemap": "mars-hillshade"})).unwrap(); assert_eq!(s.map().body().id, "mars"); assert_eq!(s.map().tiles.len(), 1); let text = serde_json::to_string(&s.project).unwrap(); let p = Project::from_json(&text).unwrap(); assert_eq!(p.maps.first().map(|m| m.body().id), Some("mars")); s.run("map.addTiles", &json!({"url": rustgis_map::planets::EARTH_BASEMAP.url, "id": "overlay", "overlay": true, "opacity": 0.5})) .unwrap(); // Back on the Earth the planetary basemap becomes USGS Imagery, keeping overlays; // an Earth map saves no body. let earth_view = s.run("map.body", &json!({"name": "earth"})).unwrap(); assert_eq!(earth_view["basemap"], "usgs-imagery"); assert!(s.map().body().is_earth()); let tiles = &s.map().tiles; assert_eq!(tiles.len(), 2); assert!(tiles.iter().any(|t| t.id == "overlay" && t.overlay && t.opacity == 0.5)); let imagery = tiles.iter().find(|t| !t.overlay).unwrap(); assert_eq!(imagery.id, rustgis_map::planets::EARTH_BASEMAP.id); assert_eq!(imagery.url, rustgis_map::planets::EARTH_BASEMAP.url); assert!(imagery.visible && !imagery.overlay && !imagery.tms); assert_eq!(imagery.max_zoom, 16); assert!(!serde_json::to_string(&s.project).unwrap().contains("\"body\"")); // Undo goes back to Mars. assert!(s.undo()); assert_eq!(s.map().body().id, "mars"); assert!(s.map().tiles.iter().any(|t| t.id == "planet-mars-hillshade")); assert!(s.map().tiles.iter().all(|t| t.id != "usgs-imagery")); // Bad names and mismatches are refused, and nothing changes. assert!(s.run("map.body", &json!({"name": "vulcan"})).is_err()); assert!(s.run("map.body", &json!({"name": "moon", "basemap": "mars-hillshade"})).is_err()); assert!(s.run("map.body", &json!({})).is_err()); assert_eq!(s.map().body().id, "mars"); let bodies = s.run("map.bodies", &Value::Null).unwrap(); assert_eq!(bodies["body"], "mars"); assert_eq!(bodies["bodies"].as_array().map(Vec::len), Some(rustgis_geom::BODIES.len())); // A basemap the map has no room for (overlays fill it) fails, and the map stays as it was: // on its body, with its tile layers. let mut full = Session::new(Project::default()); for i in 0..rustgis_map::tiles::MAX_TILE_LAYERS { full.run("map.addTiles", &json!({"url": format!("https://o{i}.example.com/{{z}}/{{x}}/{{y}}.png"), "overlay": true})).unwrap(); } assert!(full.run("map.body", &json!({"name": "mars"})).is_err()); assert!(full.map().body().is_earth()); assert_eq!(full.map().tiles.len(), rustgis_map::tiles::MAX_TILE_LAYERS); // The Earth's new default is also atomic when there is no room for its imagery. full.run("map.body", &json!({"name": "moon", "basemap": false})).unwrap(); assert!(full.run("map.body", &json!({"name": "earth"})).is_err()); assert_eq!(full.map().body().id, "moon"); assert_eq!(full.map().tiles.len(), rustgis_map::tiles::MAX_TILE_LAYERS); // A project naming an unknown body is on the Earth. let odd = text.replace("\"body\":\"mars\"", "\"body\":\"vulcan\""); assert_ne!(odd, text); let p = Project::from_json(&odd).unwrap(); assert!(p.maps.first().is_some_and(|m| m.body().is_earth())); // Saved again, it keeps the body's id (a newer version's, say). assert!(serde_json::to_string(&p).unwrap().contains("\"body\":\"vulcan\"")); } /// Text elements take italic, alignment and letter spacing (#213, point c): set when added or /// later, kept through a save, plain by default and not written until changed; a bad value is an /// error that changes nothing. #[test] fn text_elements_take_italic_alignment_and_spacing() { use rustgis_map::layout::{ElementKind, MAX_LETTER_SPACING, TextAlign}; let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Type"})).unwrap(); let style = |s: &Session, id: u32| match s.project.layouts[0].element(id).map(|e| &e.kind) { Some(ElementKind::Text { italic, align, letter_spacing, .. }) => (*italic, *align, *letter_spacing), _ => panic!("a text element"), }; let plain = s.run("layout.addElement", &json!({"kind": "text", "text": "Brasil"})).unwrap()["element"].as_u64().unwrap() as u32; assert_eq!(style(&s, plain), (false, TextAlign::Left, 0.0)); let saved = serde_json::to_string(&s.project.layouts[0]).unwrap(); assert!(!saved.contains("italic") && !saved.contains("letter_spacing") && !saved.contains("align"), "defaults aren't written"); let ocean = s .run( "layout.addElement", &json!({"kind": "text", "text": "OCEANO ATLÂNTICO", "italic": true, "align": "center", "letterSpacing": 4}), ) .unwrap()["element"] .as_u64() .unwrap() as u32; assert_eq!(style(&s, ocean), (true, TextAlign::Center, 4.0)); let dynamic = s.run("layout.addElement", &json!({"kind": "dynamicText", "align": "Right"})).unwrap()["element"].as_u64().unwrap() as u32; assert_eq!(style(&s, dynamic).1, TextAlign::Right); // One value changed keeps the others; spacing is clamped. s.run("layout.update", &json!({"element": ocean, "letterSpacing": 999})).unwrap(); assert_eq!(style(&s, ocean), (true, TextAlign::Center, MAX_LETTER_SPACING)); s.run("layout.update", &json!({"element": ocean, "italic": false, "align": "centre"})).unwrap(); assert_eq!(style(&s, ocean), (false, TextAlign::Center, MAX_LETTER_SPACING)); let back: rustgis_map::layout::Layout = serde_json::from_str(&serde_json::to_string(&s.project.layouts[0]).unwrap()).unwrap(); assert!(back.element(ocean).is_some_and( |e| matches!(&e.kind, ElementKind::Text { align: TextAlign::Center, letter_spacing, .. } if *letter_spacing == MAX_LETTER_SPACING) )); // A bad value names itself and changes nothing. for (bad, field) in [ (json!({"align": "justify"}), "unknown alignment"), (json!({"align": 3}), "align"), (json!({"italic": "very"}), "italic"), (json!({"letterSpacing": "wide"}), "letterSpacing"), ] { let mut p = bad.clone(); p["element"] = json!(ocean); let err = s.run("layout.update", &p).unwrap_err(); assert!(err.starts_with(field), "{err}"); let mut add = bad; add["kind"] = json!("text"); assert!(s.run("layout.addElement", &add).is_err()); } assert_eq!(style(&s, ocean), (false, TextAlign::Center, MAX_LETTER_SPACING)); } /// Text elements take a font family (#517): sans by default and not written then, serif when /// added or later, kept through a save, sans again on request; a bad value is an error that /// changes nothing. Where no serif is installed the text is drawn in the sans, and the command /// says so. #[test] fn text_elements_take_a_font_family() { use rustgis_map::layout::{ElementKind, FontFamily}; let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Type"})).unwrap(); let font = |s: &Session, id: u32| match s.project.layouts[0].element(id).map(|e| &e.kind) { Some(ElementKind::Text { font, .. }) => *font, _ => panic!("a text element"), }; let plain = s.run("layout.addElement", &json!({"kind": "text", "text": "Brasil"})).unwrap()["element"].as_u64().unwrap() as u32; assert_eq!(font(&s, plain), FontFamily::Sans); assert!(!serde_json::to_string(&s.project.layouts[0]).unwrap().contains("font"), "the sans isn't written"); let r = s.run("layout.addElement", &json!({"kind": "dynamicText", "font": "Serif"})).unwrap(); let title = r["element"].as_u64().unwrap() as u32; assert_eq!(font(&s, title), FontFamily::Serif); // Said only where the text can't be drawn as asked. let installed = rustgis_render::fonts::serif_available(); assert_eq!(r.get("warning").is_some(), !installed, "{r}"); let r = s.run("layout.update", &json!({"element": plain, "font": "serif"})).unwrap(); assert_eq!(font(&s, plain), FontFamily::Serif); assert_eq!(r.get("warning").is_some(), !installed, "{r}"); // Another change leaves the family, and says nothing of it. let r = s.run("layout.update", &json!({"element": plain, "italic": true})).unwrap(); assert_eq!((font(&s, plain), r.get("warning")), (FontFamily::Serif, None)); let back: rustgis_map::layout::Layout = serde_json::from_str(&serde_json::to_string(&s.project.layouts[0]).unwrap()).unwrap(); assert!(back.element(plain).is_some_and(|e| matches!(&e.kind, ElementKind::Text { font: FontFamily::Serif, .. }))); s.run("layout.update", &json!({"element": plain, "font": "sans-serif"})).unwrap(); assert_eq!(font(&s, plain), FontFamily::Sans); // Saved as the other layout enums are; the command's lowercase names read too (a layout // written by hand). assert!(serde_json::to_string(&s.project.layouts[0]).unwrap().contains(r#""font":"Serif""#)); for (name, f) in [("\"serif\"", FontFamily::Serif), ("\"Serif\"", FontFamily::Serif), ("\"sans\"", FontFamily::Sans)] { assert_eq!(serde_json::from_str::(name).unwrap(), f); } for bad in [json!(" "), json!(2), json!(["serif"])] { let err = s.run("layout.update", &json!({"element": title, "font": bad})).unwrap_err(); assert!(err.contains("font"), "{err}"); assert!(s.run("layout.addElement", &json!({"kind": "text", "font": bad})).is_err()); } assert_eq!(font(&s, title), FontFamily::Serif); // Only a text takes a family: another element refuses it rather than ignore it. let arrow = s.run("layout.addElement", &json!({"kind": "northArrow"})).unwrap()["element"].clone(); let err = s.run("layout.update", &json!({"element": arrow, "font": "serif"})).unwrap_err(); assert!(err.contains("only text elements"), "{err}"); assert!(s.run("layout.addElement", &json!({"kind": "northArrow", "font": "serif"})).unwrap_err().contains("only text elements")); // The page draws either way. assert!(s.run("layout.update", &json!({"element": title, "text": "Mapa do Brasil", "bold": true, "italic": true})).is_ok()); assert!(!crate::cmd::layout::layout_bytes(&mut s, 0, false, 72.0, false).unwrap().is_empty()); } /// A text element takes any installed font family by name (#762), kept by name with a generic /// fallback: drawn in the family where it is installed, else in the fallback, and said so. The /// name is written only when set; sans or serif clears it; a fallback goes with a name only. #[test] fn text_elements_take_an_installed_font_family_by_name() { use rustgis_map::layout::{ElementKind, FontFamily}; let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Type"})).unwrap(); let font = |s: &Session, id: u32| match s.project.layouts[0].element(id).map(|e| &e.kind) { Some(ElementKind::Text { font, font_name, .. }) => (*font, font_name.clone()), _ => panic!("a text element"), }; // A family this computer hasn't: kept as given, drawn in its fallback (the sans unless asked), // and said. let r = s.run("layout.addElement", &json!({"kind": "text", "text": "Brasil", "font": " No Such Family 762 "})).unwrap(); let id = r["element"].as_u64().unwrap() as u32; assert_eq!(font(&s, id), (FontFamily::Sans, Some("No Such Family 762".to_string()))); assert!( r["warning"].as_str().is_some_and(|w| w.starts_with("No Such Family 762 is not installed") && w.ends_with("sans-serif")), "{r}" ); let r = s.run("layout.update", &json!({"element": id, "fallback": "serif"})).unwrap(); assert_eq!(font(&s, id), (FontFamily::Serif, Some("No Such Family 762".to_string()))); let drawn = if rustgis_render::fonts::serif_available() { "drawn in the serif" } else { "drawn in the sans-serif" }; assert!(r["warning"].as_str().is_some_and(|w| w.ends_with(drawn)), "{r}"); // A family not installed whose name says it is a serif falls back to the serif. let other = s.run("layout.addElement", &json!({"kind": "text", "font": "No Such Serif 762"})).unwrap()["element"].as_u64().unwrap() as u32; assert_eq!(font(&s, other).0, FontFamily::Serif); s.run("layout.update", &json!({"element": other, "font": "sans"})).unwrap(); // Named again, it keeps that fallback; another change says nothing of it. s.run("layout.update", &json!({"element": id, "font": "No Such Family 762"})).unwrap(); assert_eq!(font(&s, id).0, FontFamily::Serif); assert_eq!(s.run("layout.update", &json!({"element": id, "bold": true})).unwrap(), Value::Null); // Kept through a save, as a name; a layout saved before has none. let saved = serde_json::to_string(&s.project.layouts[0]).unwrap(); assert!(saved.contains(r#""font_name":"No Such Family 762""#) && saved.contains(r#""font":"Serif""#), "{saved}"); let back: rustgis_map::layout::Layout = serde_json::from_str(&saved).unwrap(); assert!(back.element(id).is_some_and(|e| matches!(&e.kind, ElementKind::Text { font_name: Some(n), .. } if n == "No Such Family 762"))); let old = saved.replace(r#","font_name":"No Such Family 762""#, ""); let back: rustgis_map::layout::Layout = serde_json::from_str(&old).unwrap(); assert!(back.element(id).is_some_and(|e| matches!(&e.kind, ElementKind::Text { font_name: None, .. }))); // The page draws in the fallback, and the SVG names the family first. assert!(!crate::cmd::layout::layout_bytes(&mut s, 0, false, 72.0, false).unwrap().is_empty()); let svg = rustgis_render::svg::layout_svg(&s.project.layouts[0], &mut s.project.maps, 72.0, &Default::default()).unwrap().0; assert!(svg.contains(r#"font-family="'No Such Family 762', serif""#), "{svg}"); // A name's own quote and backslash are escaped for CSS, not dropped. s.run("layout.update", &json!({"element": id, "font": r"O'Brien\Sans", "fallback": "sans"})).unwrap(); let svg = rustgis_render::svg::layout_svg(&s.project.layouts[0], &mut s.project.maps, 72.0, &Default::default()).unwrap().0; assert!(svg.contains(r#"font-family="'O\'Brien\\Sans', sans-serif""#), "{svg}"); // A generic name with control characters around it is still the generic family. s.run("layout.update", &json!({"element": id, "font": "serif\u{0}"})).unwrap(); assert_eq!(font(&s, id), (FontFamily::Serif, None)); // A generic family clears the name; a fallback then has nothing to fall back from. s.run("layout.update", &json!({"element": id, "font": "sans"})).unwrap(); assert_eq!(font(&s, id), (FontFamily::Sans, None)); assert!(!serde_json::to_string(&s.project.layouts[0]).unwrap().contains("font"), "nothing written for the sans"); for bad in [json!({"fallback": "serif"}), json!({"font": "serif", "fallback": "sans"}), json!({"font": "Arial", "fallback": "mono"})] { let mut p = bad.clone(); p["element"] = json!(id); assert!(s.run("layout.update", &p).is_err(), "{bad}"); } assert_eq!(font(&s, id), (FontFamily::Sans, None), "a refused call changes nothing"); let arrow = s.run("layout.addElement", &json!({"kind": "northArrow"})).unwrap()["element"].clone(); assert!(s.run("layout.update", &json!({"element": arrow, "fallback": "serif"})).unwrap_err().contains("only text elements")); // An installed family, where this computer has a small one (its file read whatever the // session's budget): kept as the font names it, its fallback its own class, and nothing said. let small = |f: &&rustgis_render::families::Family| { f.listed && f.faces.iter().all(|face| std::fs::metadata(&face.path).is_ok_and(|m| m.len() < 4 << 20)) }; if let Some(f) = rustgis_render::families::installed().iter().find(small) { let r = s.run("layout.update", &json!({"element": id, "font": f.name.to_uppercase()})).unwrap(); let class = if f.serif { FontFamily::Serif } else { FontFamily::Sans }; assert_eq!(font(&s, id), (class, Some(f.name.clone()))); assert_eq!(r, Value::Null, "an installed family draws as asked"); assert!(!crate::cmd::layout::layout_bytes(&mut s, 0, false, 72.0, false).unwrap().is_empty()); } } /// Each undo step remembers who made it, through undo and redo: the user (no actor) or the /// agent the MCP relay named. #[test] fn undo_steps_remember_who_made_them() { let mut s = world(); assert_eq!(s.undo_actor(), None); s.run("layer.rename", &json!({"layer": "Rivers", "name": "Streams"})).unwrap(); assert_eq!(s.undo_actor(), Some(None)); s.actor = Some("agent 1".into()); s.run("layer.rename", &json!({"layer": "Lakes", "name": "Ponds"})).unwrap(); s.actor = None; assert_eq!(s.undo_actor(), Some(Some("agent 1"))); s.run("edit.undo", &json!({})).unwrap(); assert_eq!((s.undo_actor(), s.redo_actor()), (Some(None), Some(Some("agent 1")))); s.run("edit.redo", &json!({})).unwrap(); assert_eq!((s.undo_actor(), s.redo_actor()), (Some(Some("agent 1")), None)); } /// Unsaved means unsaved (#906): undo or redo back to the state last saved leaves no unsaved /// changes, whichever way it gets there; an edit after undoing past the save leaves that state /// out of reach. #[test] fn undo_back_to_the_saved_state_leaves_nothing_unsaved() { let dir = std::env::temp_dir().join(format!("rustgis-saved-state-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("atlas.rgis").display().to_string(); let mut s = world(); let rename = |s: &mut Session, name: &str| s.run("layer.rename", &json!({"layer": s.map().layers[0].name.clone(), "name": name})).unwrap(); rename(&mut s, "One"); s.run("project.saveAs", &json!({"path": path})).unwrap(); assert!(!s.dirty()); rename(&mut s, "Two"); rename(&mut s, "Three"); assert!(s.dirty()); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.dirty(), "one step from the save"); s.run("edit.undo", &Value::Null).unwrap(); assert!(!s.dirty(), "back at the save"); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.dirty(), "before the save"); s.run("edit.redo", &Value::Null).unwrap(); assert!(!s.dirty(), "redo back to the save"); s.run("edit.redo", &Value::Null).unwrap(); assert!(s.dirty()); // Saving there: the states either side are unsaved. s.run("project.save", &Value::Null).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.dirty()); s.run("edit.redo", &Value::Null).unwrap(); assert!(!s.dirty()); // Steps merged into one: undo takes them all back to the save. s.merge_undo("nudge"); rename(&mut s, "Four"); s.merge_undo("nudge"); rename(&mut s, "Five"); assert!(s.dirty()); s.run("edit.undo", &Value::Null).unwrap(); assert!(!s.dirty()); // A failed command leaves the state as it was, and a project it replaced on the way gives // back its saved state with its history. assert!(s.run("layer.rename", &json!({"layer": "Two", "name": ""})).is_err()); assert!(!s.dirty()); rename(&mut s, "Six"); let r = s.guarded("Task", true, |s| { s.run("project.new", &json!({"name": "Other"}))?; Err("a later step failed".into()) }); assert!(r.is_err() && s.dirty()); s.run("edit.undo", &Value::Null).unwrap(); assert!(!s.dirty(), "the save is still the one undo returns to"); // An edit after undoing past the save: no way back to it. States are compared, not contents: // the same name typed again is a change. s.run("edit.undo", &Value::Null).unwrap(); rename(&mut s, "Two"); assert!(s.dirty() && !s.can_redo()); // Recovered work is unsaved whatever undo does; opening a project starts saved. s.mark_unsaved(); assert!(s.dirty()); s.run("project.open", &json!({"path": path})).unwrap(); assert!(!s.dirty()); // A project opened by a command that is then undone isn't the one saved. let r = s.guarded("Task", true, |s| s.run("project.new", &json!({"name": "Other"}))); assert!(r.is_ok() && s.dirty()); s.run("edit.undo", &Value::Null).unwrap(); assert!(s.dirty(), "the project before the task, not the new one's saved state"); std::fs::remove_dir_all(&dir).ok(); } /// A legend can leave layers out though the map draws them: set when added or later, checked /// against its frame's map, kept through a save (not written when empty), and following a /// renamed layer. #[test] fn legends_leave_layers_out() { use rustgis_map::layout::ElementKind; let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Legends"})).unwrap(); let left_out = |s: &Session, id: u32| match s.project.layouts[0].element(id).map(|e| &e.kind) { Some(ElementKind::Legend { exclude, .. }) => exclude.clone(), _ => panic!("a legend"), }; let plain = s.run("layout.addElement", &json!({"kind": "legend"})).unwrap()["element"].as_u64().unwrap() as u32; assert!(left_out(&s, plain).is_empty()); let saved = serde_json::to_string(&s.project.layouts[0]).unwrap(); assert!(!saved.contains("exclude"), "an empty list isn't written"); // Names as the map spells them, once each. let lakes = s.run("layout.addElement", &json!({"kind": "legend", "exclude": ["lakes", "LAKES", "Rivers"]})).unwrap()["element"] .as_u64() .unwrap() as u32; assert_eq!(left_out(&s, lakes), ["Lakes", "Rivers"]); s.run("layout.update", &json!({"element": lakes, "exclude": ["Rivers"]})).unwrap(); assert_eq!(left_out(&s, lakes), ["Rivers"]); // A layer the map hasn't, or not a list of names, is an error that changes nothing; so is // `exclude` on another kind of element. for bad in [json!(["Atlantis"]), json!("Rivers"), json!([3]), json!([null]), json!([""]), json!([{"x": 1}]), json!(["Rivers", 7])] { assert!(s.run("layout.update", &json!({"element": lakes, "exclude": bad})).is_err(), "{bad}"); assert!(s.run("layout.addElement", &json!({"kind": "legend", "exclude": bad})).is_err(), "{bad}"); } assert_eq!(left_out(&s, lakes), ["Rivers"]); let title = s.project.layouts[0].elements.iter().find(|e| e.name == "Title").unwrap().id; assert!(s.run("layout.update", &json!({"element": title, "exclude": ["Rivers"]})).unwrap_err().contains("only legends")); assert!(s.run("layout.addElement", &json!({"kind": "northArrow", "exclude": ["Rivers"]})).unwrap_err().contains("only legends")); // Renaming the layer keeps it left out. s.run("layer.rename", &json!({"layer": "Rivers", "name": "Rios"})).unwrap(); assert_eq!(left_out(&s, lakes), ["Rios"]); let back: rustgis_map::layout::Layout = serde_json::from_str(&serde_json::to_string(&s.project.layouts[0]).unwrap()).unwrap(); assert!(back.element(lakes).is_some_and(|e| matches!(&e.kind, ElementKind::Legend { exclude, .. } if exclude == &["Rios"]))); // Names may repeat. Renaming Lakes onto a name already left out keeps it once; with two // layers named Rios, removing one keeps the name left out for the other. s.run("layout.update", &json!({"element": lakes, "exclude": ["Rios", "Lakes"]})).unwrap(); s.run("layer.rename", &json!({"layer": "Lakes", "name": "Rios"})).unwrap(); assert_eq!(left_out(&s, lakes), ["Rios"], "once each"); assert_eq!(s.map().layers.iter().filter(|l| l.name == "Rios").count(), 2); let first = s.map().layers.iter().find(|l| l.name == "Rios").unwrap().id; s.run("layer.remove", &json!({"layer": first})).unwrap(); assert_eq!(left_out(&s, lakes), ["Rios"], "the other Rios is still left out"); // Deleting the layer drops it from the list, so a later layer of that name is listed. s.run("layer.remove", &json!({"layer": "Rios"})).unwrap(); assert!(left_out(&s, lakes).is_empty()); // An empty list lists every layer again. s.run("layout.update", &json!({"element": lakes, "exclude": ["Countries"]})).unwrap(); s.run("layout.update", &json!({"element": lakes, "exclude": []})).unwrap(); assert!(left_out(&s, lakes).is_empty()); // Removing a group with its layers drops them too. s.run("layout.update", &json!({"element": lakes, "exclude": ["Cities", "Countries"]})).unwrap(); s.run("group.add", &json!({"name": "Places", "layers": ["Cities"]})).unwrap(); s.run("group.remove", &json!({"group": "Places"})).unwrap(); assert_eq!(left_out(&s, lakes), ["Countries"]); // A list past the cap is refused rather than cut short. let many = vec!["Countries"; rustgis_map::layout::MAX_LEGEND_EXCLUDED + 1]; assert!(s.run("layout.update", &json!({"element": lakes, "exclude": many})).unwrap_err().contains("at most")); assert_eq!(left_out(&s, lakes), ["Countries"]); // A frame naming its map in another case (as an imported layout may) still takes a list and // follows renames, as the renderer finds the map. let lower = s.map().name.to_lowercase(); assert_ne!(lower, s.map().name); for e in &mut s.project.layouts[0].elements { if let ElementKind::MapFrame { map, .. } = &mut e.kind { map.clone_from(&lower); } } s.run("layout.update", &json!({"element": lakes, "exclude": ["countries"]})).unwrap(); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); assert_eq!(left_out(&s, lakes), ["Nations"]); // Renamed onto the name of a layer the legend lists, a layer left out is listed with it. s.run("layer.addSample", &json!({"name": "lakes"})).unwrap(); s.run("layout.update", &json!({"element": lakes, "exclude": ["Lakes"]})).unwrap(); s.run("layer.rename", &json!({"layer": "Nations", "name": "Countries"})).unwrap(); s.run("layer.rename", &json!({"layer": "Lakes", "name": "Countries"})).unwrap(); assert!(left_out(&s, lakes).is_empty(), "the other Countries isn't taken out"); // A WMS or WMTS layer is left out by name too, and the name follows it: renamed, removed. let wms = json!({"url": "https://maps.example.org/wms/{bbox-epsg-3857}", "name": "Soils", "overlay": true, "legend": "https://maps.example.org/legend.png"}); s.run("map.addTiles", &wms).unwrap(); s.run("layout.update", &json!({"element": lakes, "exclude": ["soils"]})).unwrap(); assert_eq!(left_out(&s, lakes), ["Soils"]); s.run("map.setTiles", &json!({"name": "Soils", "rename": "Soil map", "move": "bottom"})).unwrap(); assert_eq!(left_out(&s, lakes), ["Soil map"]); s.run("map.removeTiles", &json!({"name": "Soil map"})).unwrap(); assert!(left_out(&s, lakes).is_empty()); } /// A legend can show a layer under a label instead of its name (#1654): set when added or later, /// entry by entry, `null` or `{}` going back to the name; checked against its frame's map; kept /// through a saved project and layout file (not written when empty); following a renamed layer /// and dropped with a removed one. #[test] fn legends_label_layers() { use rustgis_map::layout::ElementKind; let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Rótulos"})).unwrap(); let labels = |s: &Session, id: u32| -> Vec<(String, String)> { match s.project.layouts[0].element(id).map(|e| &e.kind) { Some(ElementKind::Legend { labels, .. }) => labels.iter().map(|(n, l)| (n.clone(), l.clone())).collect(), _ => panic!("a legend"), } }; let pair = |n: &str, l: &str| (n.to_string(), l.to_string()); let plain = s.run("layout.addElement", &json!({"kind": "legend"})).unwrap()["element"].as_u64().unwrap() as u32; assert!(labels(&s, plain).is_empty()); assert!(!serde_json::to_string(&s.project.layouts[0]).unwrap().contains("labels"), "none isn't written"); // Names as the map spells them; a blank label is none. let id = s.run("layout.addElement", &json!({"kind": "legend", "labels": {"rivers": "Rios", "LAKES": " "}})).unwrap()["element"] .as_u64() .unwrap() as u32; assert_eq!(labels(&s, id), [pair("Rivers", "Rios")]); // Entries put in over those it has; null takes one back to the name. s.run("layout.update", &json!({"element": id, "labels": {"Lakes": "Lagos", "Cities": "Cidades"}})).unwrap(); assert_eq!(labels(&s, id), [pair("Cities", "Cidades"), pair("Lakes", "Lagos"), pair("Rivers", "Rios")]); s.run("layout.update", &json!({"element": id, "labels": {"cities": null, "RIVERS": "Rios principais"}})).unwrap(); assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]); // A layer the map hasn't, a layer named twice, or not an object of labels is an error that // changes nothing; so is `labels` on another kind of element. for bad in [ json!({"Atlantis": "x"}), json!({"Rivers": "a", "rivers": "b"}), json!({"Rivers": 3}), json!({"Rivers": ["Rios"]}), json!(["Rivers"]), json!("Rivers"), ] { assert!(s.run("layout.update", &json!({"element": id, "labels": bad})).is_err(), "{bad}"); assert!(s.run("layout.addElement", &json!({"kind": "legend", "labels": bad})).is_err(), "{bad}"); } assert!(s.run("layout.update", &json!({"element": id, "labels": {"Atlantis": "x"}})).unwrap_err().contains("no layer 'Atlantis'")); assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]); let title = s.project.layouts[0].elements.iter().find(|e| e.name == "Title").unwrap().id; assert!(s.run("layout.update", &json!({"element": title, "labels": {"Rivers": "Rios"}})).unwrap_err().contains("only legends")); assert!(s.run("layout.addElement", &json!({"kind": "scaleBar", "labels": {"Rivers": "Rios"}})).unwrap_err().contains("only legends")); // Any other value is refused too, rather than left unread, and nothing is added. let count = s.project.layouts[0].elements.len(); for v in [json!("Rivers"), json!(["Rivers"]), json!(true)] { assert!(s.run("layout.update", &json!({"element": title, "labels": v})).unwrap_err().contains("only legends"), "{v}"); assert!(s.run("layout.addElement", &json!({"kind": "scaleBar", "labels": v})).unwrap_err().contains("only legends"), "{v}"); } let err = s.run("layout.addElement", &json!({"kind": "grid", "labels": "no"})).unwrap_err(); assert!(err.contains("a grid's labels is true or false"), "{err}"); assert_eq!(s.project.layouts[0].elements.len(), count); // A grid's own "labels" (a bool) is still its. let frame = s.run("layout.addElement", &json!({"kind": "grid", "labels": false})).unwrap()["element"].as_u64().unwrap() as u32; assert!(matches!( s.project.layouts[0].element(frame).map(|e| &e.kind), Some(ElementKind::MapFrame { grid: Some(g), .. }) if !g.labels )); // On layout.update they go under "grid", and a top-level "labels" there says so. let err = s.run("layout.update", &json!({"element": frame, "labels": true})).unwrap_err(); assert!(err.contains(r#"go under "grid""#), "{err}"); s.run("layout.update", &json!({"element": frame, "grid": {"labels": true}})).unwrap(); assert!(matches!( s.project.layouts[0].element(frame).map(|e| &e.kind), Some(ElementKind::MapFrame { grid: Some(g), .. }) if g.labels )); // A label longer than a legend keeps is refused, not cut, and changes nothing; one at the // limit is kept whole. let before = labels(&s, plain); let long = "x".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS + 1); let err = s.run("layout.update", &json!({"element": plain, "labels": {"Countries": long}})).unwrap_err(); assert!(err.contains("at most 200 characters") && err.contains("has 201"), "{err}"); assert_eq!(labels(&s, plain), before); let full = "é".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS); s.run("layout.update", &json!({"element": plain, "labels": {"Countries": full}})).unwrap(); assert_eq!(labels(&s, plain)[0].1, full); s.run("layout.update", &json!({"element": plain, "labels": {"Countries": null}})).unwrap(); // Kept through a saved project, and a layout file. let dir = std::env::temp_dir().join(format!("rustgis-legend-labels-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("rotulos.rgis"); s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); s.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); assert_eq!(labels(&s, id), [pair("Lakes", "Lagos"), pair("Rivers", "Rios principais")]); let file = dir.join("rotulos.rgislayout"); s.run("layout.save", &json!({"path": file.display().to_string()})).unwrap(); s.run("layout.import", &json!({"path": file.display().to_string()})).unwrap(); assert!(matches!( s.project.layouts[1].element(id).map(|e| &e.kind), Some(ElementKind::Legend { labels, .. }) if labels.get("Rivers").is_some_and(|l| l == "Rios principais") )); let cut_notice = |s: &Session, from: usize| s.notices[from..].iter().filter(|n| n.text.contains("legend labels cut to their limits")).count(); assert_eq!(cut_notice(&s, 0), 0, "nothing was cut"); // A layout file with a label longer than a legend keeps is read with it cut, and says so. let mut text: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); let legend = text["elements"].as_array_mut().unwrap().iter_mut().find(|e| e["id"] == json!(id)).unwrap(); legend["kind"]["Legend"]["labels"]["Rivers"] = json!("x".repeat(rustgis_map::layout::MAX_LEGEND_LABEL_CHARS + 50)); let mark = s.notices.len(); s.run("layout.import", &json!({"text": text.to_string()})).unwrap(); assert_eq!(cut_notice(&s, mark), 1); assert!(matches!( s.project.layouts[2].element(id).map(|e| &e.kind), Some(ElementKind::Legend { labels, .. }) if labels["Rivers"].chars().count() == rustgis_map::layout::MAX_LEGEND_LABEL_CHARS )); // One with two spellings of a layer keeps the first in order, as a command keeps one, and // says so. let legend = text["elements"].as_array_mut().unwrap().iter_mut().find(|e| e["id"] == json!(id)).unwrap(); legend["kind"]["Legend"]["labels"] = json!({"Rivers": "Rios", "rivers": "rios", "Lakes": "Lagos"}); let mark = s.notices.len(); s.run("layout.import", &json!({"text": text.to_string()})).unwrap(); assert_eq!(cut_notice(&s, mark), 1); assert!(matches!( s.project.layouts[3].element(id).map(|e| &e.kind), Some(ElementKind::Legend { labels, .. }) if labels.iter().map(|(n, l)| (n.as_str(), l.as_str())).collect::>() == [("Lakes", "Lagos"), ("Rivers", "Rios")] )); std::fs::remove_dir_all(&dir).ok(); // (The imported layout is the active one now; the rest goes to the first.) // The label follows its layer renamed, and goes with it removed, so a later layer of that // name isn't labelled unasked. let lost = |s: &Session, from: usize| s.notices[from..].iter().filter(|n| n.text.contains("lost its label")).count(); let mark = s.notices.len(); s.run("layer.rename", &json!({"layer": "Rivers", "name": "Hidrografia"})).unwrap(); assert_eq!(labels(&s, id), [pair("Hidrografia", "Rios principais"), pair("Lakes", "Lagos")]); s.run("layer.remove", &json!({"layer": "Lakes"})).unwrap(); assert_eq!(labels(&s, id), [pair("Hidrografia", "Rios principais")]); // Renamed onto the name of a layer the legend lists, a layer shares that one's label (here // none) rather than giving it its own. s.run("layer.rename", &json!({"layer": "Hidrografia", "name": "Cities"})).unwrap(); // Its label is dropped, and said once for the four legends that had it (this one and the // three layouts imported above); following a rename or a removal is not. assert_eq!(lost(&s, mark), 1); assert!(s.notices[mark..].iter().any(|n| n.text.contains("'Hidrografia', renamed 'Cities', lost its label in 4 legend(s)"))); assert!(labels(&s, id).is_empty()); // A WMS or WMTS layer is labelled by name too. let wms = json!({"url": "https://maps.example.org/wms/{bbox-epsg-3857}", "name": "Soils", "overlay": true, "legend": "https://maps.example.org/legend.png"}); s.run("map.addTiles", &wms).unwrap(); s.run("layout.update", &json!({"layout": 0, "element": id, "labels": {"soils": "Solos"}})).unwrap(); assert_eq!(labels(&s, id), [pair("Soils", "Solos")]); s.run("map.setTiles", &json!({"name": "Soils", "rename": "Soil map"})).unwrap(); assert_eq!(labels(&s, id), [pair("Soil map", "Solos")]); // An empty object shows every layer's name again. s.run("layout.update", &json!({"layout": 0, "element": id, "labels": {}})).unwrap(); assert!(labels(&s, id).is_empty()); assert!(!serde_json::to_string(&s.project.layouts[0].element(id).unwrap()).unwrap().contains("labels")); } /// A legend given labels says when they don't fit its box, added or changed, in one column too /// (#1654); labels that fit say nothing. #[test] fn a_legends_labels_say_when_they_dont_fit() { let mut s = Session::new(sample::world_project()); s.run("layout.new", &json!({"name": "Rótulos"})).unwrap(); let said = |s: &Session, mark: u64| s.notices_since(mark).iter().map(|n| n.text.clone()).collect::>().join(" | "); let cut = "the ends of long labels are left out"; let box_ = json!({"kind": "legend", "x": 10, "y": 10, "w": 150, "h": 180}); let with = |labels: Value| { let mut p = box_.clone(); p["labels"] = labels; p }; let mark = s.notice_mark(); let id = s.run("layout.addElement", &with(json!({"Rivers": "Rios"}))).unwrap()["element"].as_u64().unwrap(); assert_eq!(said(&s, mark), ""); let long = "Rios principais da América do Sul ".repeat(5); let mark = s.notice_mark(); s.run("layout.addElement", &with(json!({"Rivers": long}))).unwrap(); assert!(said(&s, mark).contains(cut), "{}", said(&s, mark)); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "labels": {"Rivers": long}})).unwrap(); assert!(said(&s, mark).contains(cut), "{}", said(&s, mark)); let mark = s.notice_mark(); s.run("layout.update", &json!({"element": id, "labels": {"Rivers": null}})).unwrap(); assert_eq!(said(&s, mark), ""); } /// A point layer of events, one a month through 2015 (two in March), with their dates as text, /// epoch milliseconds and years. fn timed_events() -> Session { isolate_plugins(); let mut feats = Vec::new(); let dates = ["2015-01-10", "2015-02-03", "2015-03-01", "2015-03-30", "2015-04-15", "2015-06-20", "2015-09-09", "2015-12-25"]; for (i, d) in dates.iter().enumerate() { let ms = rustgis_data::time::parse_text(d).unwrap(); feats.push(json!({"type": "Feature", "geometry": {"type": "Point", "coordinates": [i as f64, 0.0]}, "properties": {"name": format!("e{i}"), "when": d, "ms": ms, "year": 2010 + i}})); } let text = json!({"type": "FeatureCollection", "features": feats}).to_string(); let (ds, _) = rustgis_data::io::read_geojson(&text, "Events").unwrap(); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s } fn shown(s: &Session) -> Vec { let l = s.map().layer_by_name("Events").unwrap(); let fi = l.dataset.field_index("name").unwrap(); l.drawn_rows(s.scale()).iter().enumerate().filter(|(_, k)| **k).map(|(r, _)| l.dataset.value(r, fi).to_string()).collect() } #[test] fn time_slider_shows_the_features_of_its_window() { let mut s = timed_events(); // The date fields, best first: text dates and epoch milliseconds all read; years too. let fields = s.run("time.fields", &Value::Null).unwrap(); let names: Vec<&str> = fields["fields"].as_array().unwrap().iter().map(|f| f["field"].as_str().unwrap()).collect(); assert!(names.contains(&"when") && names.contains(&"ms") && names.contains(&"year"), "{names:?}"); assert!(!names.contains(&"name")); // Bound by its text dates: a slider by month over the layer's year, at January. let r = s.run("time.bind", &json!({"field": "when"})).unwrap(); assert_eq!((r["unit"].as_str(), r["steps"].as_u64(), r["date"].as_str()), (Some("month"), Some(12), Some("2015-01")), "{r}"); assert_eq!(shown(&s), ["e0"]); s.run("time.goTo", &json!({"date": "2015-03-15"})).unwrap(); assert_eq!(shown(&s), ["e2", "e3"]); // Stepping: May is empty, wrapping goes back to January. s.run("time.step", &json!({"by": 2})).unwrap(); assert!(shown(&s).is_empty()); s.run("time.step", &json!({"by": 8, "wrap": true})).unwrap(); assert_eq!(s.run("time.info", &Value::Null).unwrap()["date"], "2015-01"); // Wider and cumulative windows. s.run("time.set", &json!({"date": "2015-04", "show": "wide"})).unwrap(); assert_eq!(shown(&s), ["e2", "e3", "e4"]); s.run("time.set", &json!({"show": "cumulative"})).unwrap(); assert_eq!(shown(&s), ["e0", "e1", "e2", "e3", "e4"]); // Epoch milliseconds read the same, and the layer's legend follows the window. s.run("time.set", &json!({"show": "step", "date": "2015-12"})).unwrap(); s.run("time.bind", &json!({"field": "ms"})).unwrap(); assert_eq!(shown(&s), ["e7"]); // A quarter a step. s.run("time.set", &json!({"date": "2015-01", "interval": 3})).unwrap(); assert_eq!(s.run("time.info", &Value::Null).unwrap()["steps"], 4); assert_eq!(shown(&s), ["e0", "e1", "e2", "e3"]); // Saved and opened again, the slider and the binding hold. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::from_json(&text).unwrap()); assert_eq!(shown(&t), ["e0", "e1", "e2", "e3"]); t.run("time.step", &Value::Null).unwrap(); assert_eq!(shown(&t), ["e4", "e5"]); // Bound by year numbers (2010–2017), a step a year. t.run("time.set", &json!({"off": true})).unwrap(); assert_eq!(shown(&t).len(), 8); t.run("time.bind", &json!({"layer": "Events", "field": "year"})).unwrap(); assert_eq!(t.run("time.info", &Value::Null).unwrap()["unit"], "year"); t.run("time.goTo", &json!({"date": 2012})).unwrap(); assert_eq!(shown(&t), ["e2"]); // Unbound, everything shows again; undo binds it again. t.run("time.unbind", &json!({"layer": "Events"})).unwrap(); assert_eq!(shown(&t).len(), 8); t.run("edit.undo", &Value::Null).unwrap(); assert_eq!(shown(&t), ["e2"]); // Irregular dates: steps only at those, each window up to the next. t.run("time.bind", &json!({"layer": "Events", "field": "when"})).unwrap(); t.run("time.set", &json!({"dates": ["2015-01-01", "2015-03-01", "2015-09-01"], "date": "2015-03-01"})).unwrap(); assert_eq!(t.run("time.info", &Value::Null).unwrap()["steps"], 3); assert_eq!(shown(&t), ["e2", "e3", "e4", "e5"]); for bad in [json!({"layer": "Events", "field": "nope"}), json!({"layer": "Events", "field": "name"})] { assert!(t.run("time.bind", &bad).is_err(), "{bad}"); } for bad in [json!({"unit": "fortnight"}), json!({"start": "soon"}), json!({"show": "all"}), json!({"dates": "2015"})] { assert!(t.run("time.set", &bad).is_err(), "{bad}"); } } #[test] fn time_slider_survives_saving_cumulative_edits_and_bad_parameters() { let mut s = timed_events(); // Text and epoch dates come before whole numbers that may only look like years. let fields = s.run("time.fields", &Value::Null).unwrap(); assert_eq!(fields["fields"].as_array().unwrap().last().unwrap()["field"], "year"); // A bad parameter leaves nothing behind: no slider, no binding. assert!(s.run("time.bind", &json!({"field": "when", "unit": "fortnight"})).is_err()); assert!(s.map().time.is_none() && s.map().layers[0].time.is_none()); // Settings without a span don't turn an off slider on. assert!(s.run("time.set", &json!({"loop": false})).is_err()); assert!(s.map().time.is_none()); s.run("time.bind", &json!({"field": "when"})).unwrap(); s.run("time.set", &json!({"date": "2015-03", "show": "cumulative"})).unwrap(); assert_eq!(shown(&s), ["e0", "e1", "e2", "e3"]); // Saved with its unbounded window, and opened again. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::from_json(&text).unwrap()); assert_eq!(shown(&t), ["e0", "e1", "e2", "e3"]); // An edited date moves its feature in or out of the window at once. t.active_layer = t.map().layer_by_name("Events").map(|l| l.id); t.run("edit.setValue", &json!({"row": 7, "field": "when", "value": "2015-02-14"})).unwrap(); assert_eq!(shown(&t), ["e0", "e1", "e2", "e3", "e7"]); // Undo takes the edit back, not the slider's moves since. t.run("time.goTo", &json!({"date": "2015-04"})).unwrap(); t.run("edit.undo", &Value::Null).unwrap(); assert_eq!(t.run("time.info", &Value::Null).unwrap()["date"], "2015-04"); assert_eq!(shown(&t), ["e0", "e1", "e2", "e3", "e4"]); t.run("edit.redo", &Value::Null).unwrap(); assert_eq!(t.run("time.info", &Value::Null).unwrap()["date"], "2015-04"); t.run("time.goTo", &json!({"date": "2015-03"})).unwrap(); // Whole numbers that look like years (heights here) aren't taken for dates unasked; a layer // with real dates is. assert!(rustgis_map::time::has_dates(&t.map().layers[0].dataset)); let text = r#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":null,"properties":{"elev":1500}},{"type":"Feature","geometry":null,"properties":{"elev":2210}}]}"#; let (heights, _) = rustgis_data::io::read_geojson(text, "Peaks").unwrap(); assert!(!rustgis_map::time::has_dates(&heights)); assert_eq!(rustgis_map::time::time_fields(&heights).len(), 1, "still offered when asked"); // Pasted into a map without a slider, the copy shows every feature (no window comes along). t.run("layer.copy", &json!({"layer": "Events"})).unwrap(); t.run("map.new", &json!({"name": "Plain"})).unwrap(); t.run("layer.paste", &Value::Null).unwrap(); assert!(t.map().time.is_none()); assert_eq!(shown(&t).len(), 8); t.run("map.activate", &json!({"name": "Map"})).unwrap(); // Features changed outside a command (as a streamed layer reads another view), with as many // rows: the window is worked out again for them, not the old rows' mask reused. { let l = t.map_mut().layers.iter_mut().find(|l| l.name == "Events").unwrap(); let fi = l.dataset.field_index("when").unwrap(); l.dataset.features[0].attrs[fi] = rustgis_data::Value::Text("2015-12-01".into()); } assert_eq!(shown(&t), ["e1", "e2", "e3", "e7"]); // A field that is gone filters nothing. t.run("edit.deleteField", &json!({"field": "when"})).unwrap(); assert_eq!(shown(&t).len(), 8); } #[test] fn time_slider_fills_dated_tile_addresses_and_exports_its_steps() { let mut s = timed_events(); let r = s .run( "map.addTiles", &json!({"url": "https://example.com/{date:YYYY-MM-DD}/{z}/{x}/{y}.png", "start": "2023-08-01", "end": "2023-08-31"}), ) .unwrap(); assert_eq!(r["url"], "https://example.com/2023-08-01/{z}/{x}/{y}.png"); let mut keyed_session = timed_events(); let keyed = keyed_session.run("map.addTiles", &json!({"url":"https://example.com/{date:YYYY-MM-DD}/{z}/{x}/{y}.png?token=RGSYNTH_dated_tiles","start":"2023-08-01","end":"2023-08-31"})).unwrap(); assert!(!keyed["url"].as_str().unwrap().contains("RGSYNTH_dated_tiles")); assert!(keyed_session.map().tiles[keyed["index"].as_u64().unwrap() as usize].url.contains("RGSYNTH_dated_tiles")); let i = s.run("time.info", &Value::Null).unwrap(); assert_eq!((i["unit"].as_str(), i["steps"].as_u64()), (Some("day"), Some(31)), "{i}"); // Only tile kinds that fill dates take a dated address. let e = s.run("map.addTiles", &json!({"url": "https://example.com/{YYYY}.pmtiles", "start": 2000})).unwrap_err(); assert!(e.contains("dated addresses"), "{e}"); // A COG's look (its bands, its range) stays when the date changes. let mut look = timed_events(); look.run("map.addTiles", &json!({"url": "https://example.com/{YYYY}.tif", "kind": "cog", "start": 2000, "end": 2002})).unwrap(); look.run("map.setTiles", &json!({"index": 0, "bands": [3, 2, 1], "range": [0, 3000]})).unwrap(); let shown_look = look.map().tiles[0].url.split_once('#').map(|(_, l)| l.to_string()).unwrap_or_default(); assert!(!shown_look.is_empty(), "{}", look.map().tiles[0].url); look.run("time.goTo", &json!({"date": 2002})).unwrap(); let url = look.map().tiles[0].url.clone(); assert!(url.starts_with("https://example.com/2002.tif#") && url.ends_with(&shown_look), "{url}"); // A scheme in capitals is still a web address that follows the slider. let mut caps = timed_events(); caps.run("map.addTiles", &json!({"url": "HTTPS://example.com/{date:YYYY}/{z}/{x}/{y}.png", "start": 2000, "end": 2002})).unwrap(); caps.run("time.goTo", &json!({"date": 2002})).unwrap(); assert!(caps.map().tiles[0].url.contains("/2002/"), "{}", caps.map().tiles[0].url); // A dated address with the slider off needs its span. let mut off = timed_events(); assert!(off.run("map.addTiles", &json!({"url": "https://example.com/{date:YYYY}/{z}/{x}/{y}.png"})).is_err()); assert!(off.map().tiles.is_empty() && off.map().time.is_none()); // Dated addresses only for GeoTIFFs on the web, refused before anything is fetched. for url in ["https://example.com/{YYYY}.parquet", "s3://bucket/{YYYY}.tif"] { let e = off.run("layer.addUrl", &json!({"url": url, "start": 2000})).unwrap_err(); assert!(e.contains("dated addresses"), "{e}"); } // A later check failing (here the blend) takes back the slider the span turned on: the // session restores the project when a command fails. assert!( off.run("map.addTiles", &json!({"url": "https://example.com/{YYYY}/{z}/{x}/{y}.png", "start": 2000, "blend": "nope"})).is_err() ); assert!(off.map().tiles.is_empty() && off.map().time.is_none()); // Three days of daily imagery step by the day the address names, not by the hour. let mut short = timed_events(); short .run( "map.addTiles", &json!({"url": "https://example.com/{date:YYYY-MM-DD}/{z}/{x}/{y}.png", "start": "2023-08-28", "end": "2023-08-30"}), ) .unwrap(); let i = short.run("time.info", &Value::Null).unwrap(); assert_eq!((i["unit"].as_str(), i["steps"].as_u64()), (Some("day"), Some(3)), "{i}"); short.run("time.goTo", &json!({"step": 99})).unwrap(); assert_eq!(short.map().tiles[0].url, "https://example.com/2023-08-30/{z}/{x}/{y}.png"); short.run("time.goTo", &json!({"step": 1})).unwrap(); assert_eq!(short.map().tiles[0].url, "https://example.com/2023-08-29/{z}/{x}/{y}.png"); s.run("time.goTo", &json!({"date": "2023-08-30"})).unwrap(); assert_eq!(s.map().tiles[0].url, "https://example.com/2023-08-30/{z}/{x}/{y}.png"); // Kept in the project as its template, filled again when opened. let text = serde_json::to_string(&s.project).unwrap(); let t = Project::from_json(&text).unwrap(); assert_eq!(t.maps[0].tiles[0].url, "https://example.com/2023-08-30/{z}/{x}/{y}.png"); // A template from a file that isn't a web address is dropped, its layer left as it was. let mut p: Value = serde_json::from_str(&text).unwrap(); p["maps"][0]["tiles"][0]["timeUrl"] = json!("file:///etc/{date:YYYY}"); let t = Project::from_json(&p.to_string()).unwrap(); assert_eq!(t.maps[0].tiles[0].time_url, None); // Exported: a frame a step, the slider back where it was. s.run("time.set", &json!({"start": "2015-01-01", "end": "2015-12-31", "unit": "month"})).unwrap(); s.run("map.removeTiles", &Value::Null).unwrap(); s.run("time.bind", &json!({"field": "when"})).unwrap(); s.run("time.goTo", &json!({"date": "2015-06"})).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-time-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let gif = dir.join("events.gif"); let r = s .run("time.export", &json!({"path": gif.display().to_string(), "width": 96, "height": 64, "from": "2015-02", "to": "2015-05"})) .unwrap(); assert_eq!(r["frames"], 4); let bytes = std::fs::read(&gif).unwrap(); assert!(bytes.starts_with(b"GIF89a")); let zip = dir.join("events.zip"); assert_eq!(s.run("time.export", &json!({"path": zip.display().to_string(), "width": 96, "height": 64})).unwrap()["frames"], 12); assert_eq!(s.run("time.info", &Value::Null).unwrap()["date"], "2015-06"); assert!(s.run("time.export", &json!({"path": zip.display().to_string(), "from": "2016"})).is_err()); // Only GIFs and zips, so a file is what its name says. assert!(s.run("time.export", &json!({"path": dir.join("events.png").display().to_string()})).is_err()); // A huge step number is the last step. s.run("time.goTo", &json!({"step": u64::MAX})).unwrap(); assert_eq!(s.run("time.info", &Value::Null).unwrap()["date"], "2015-12"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn zoom_level_matches_the_map_link() { let mut s = Session::new(Project::default()); s.run("map.goToLink", &json!({"url": "#7.5/48/10"})).unwrap(); let z = s.zoom().unwrap(); assert!((z - 7.5).abs() < 0.01, "{z}"); } /// A 2×2 grey TIFF with no GeoTIFF tags, written by hand (little-endian, one strip). fn tagless_tiff() -> Vec { // (tag, type 3 = short / 4 = long, value) let entries: [(u16, u16, u32); 8] = [(256, 3, 2), (257, 3, 2), (258, 3, 8), (262, 3, 1), (273, 4, 110), (277, 3, 1), (278, 3, 2), (279, 4, 4)]; let mut out = b"II*\0\x08\0\0\0".to_vec(); out.extend((entries.len() as u16).to_le_bytes()); for (tag, kind, value) in entries { out.extend(tag.to_le_bytes()); out.extend(kind.to_le_bytes()); out.extend(1u32.to_le_bytes()); out.extend(value.to_le_bytes()); } out.extend(0u32.to_le_bytes()); out.extend([10, 20, 30, 40]); out } /// A raster with no coordinates (a TIFF without tags, a PNG, a JPEG) opens as a layer drawn over /// the view and marked as not georeferenced, from a path or from bytes; a project keeps where it /// was laid; a photo with a GPS position is still a photo. #[test] fn rasters_without_coordinates_open_laid_over_the_view() { use base64::Engine; let dir = std::env::temp_dir().join(format!("rustgis-scans-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let encode = |format| { let mut out = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([x as u8, y as u8, 9]))) .write_to(&mut out, format) .unwrap(); out.into_inner() }; let files = [ ("map.tif", tagless_tiff(), (2, 2, 1)), ("plate.png", encode(image::ImageFormat::Png), (40, 20, 3)), ("print.jpg", encode(image::ImageFormat::Jpeg), (40, 20, 3)), ]; let mut s = world(); let view = crate::cmd::workspace::view_extent(&s); for (name, bytes, (w, h, bands)) in &files { let path = dir.join(name); std::fs::write(&path, bytes).unwrap(); // A JPEG is a photo by default (without a GPS position it is named and left out). let jpeg = name.ends_with(".jpg"); let mut q = json!({"path": path.display().to_string()}); if jpeg { assert!(s.run("layer.addData", &q).is_err(), "a photo without a position is not added"); assert!(s.map().layer_by_name("print").is_none()); q["as"] = json!("scan"); } let r = s.run("layer.addData", &q).unwrap(); let stem = name.split('.').next().unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["bands"].as_u64()), (Some(*w), Some(*h), Some(*bands)), "{r}"); assert_eq!(r["georeferenced"], false, "{r}"); assert!(r["note"].as_str().is_some_and(|t| t.contains("no coordinates")), "{r}"); assert!(s.notices.iter().any(|n| n.text.contains(stem) && n.text.contains("no coordinates"))); let rl = s.map().layer_by_name(stem).unwrap().raster.clone().unwrap(); let data = rl.data.clone().unwrap(); assert!(!data.georeferenced); let e = data.extent(); assert!(e.min_x >= view.min_x && e.max_x <= view.max_x && e.min_y >= view.min_y && e.max_y <= view.max_y, "{e:?} in {view:?}"); assert!(rl.placement.is_some()); // The web app's way: the bytes. let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); let mut q = json!({"fileName": name, "data": b64, "name": format!("{stem} upload")}); if jpeg { q["as"] = json!("scan"); } let r = s.run("layer.addDataBytes", &q).unwrap(); assert_eq!((r["georeferenced"].clone(), r["width"].as_u64()), (json!(false), Some(*w)), "{r}"); } // The project lays a reopened scan where it was. let before = s.map().layer_by_name("plate").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().extent(); let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("plate").unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert!(!again.georeferenced); let (a, b) = (again.extent(), before); assert!((a.min_x - b.min_x).abs() < 1e-9 && (a.max_y - b.max_y).abs() < 1e-9, "{a:?} vs {b:?}"); // Georeferencing it gives it coordinates for real. let l = s.run("raster.georeference", &json!({"layer": "plate", "extent": [10.0, 40.0, 12.0, 41.0]})).unwrap(); assert!(l["extent"].is_array()); assert!(s.map().layer_by_name("plate").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().georeferenced); // Damaged ones are errors, and add nothing. let count = s.map().layers.len(); for (name, bytes, _) in &files { let cut = base64::engine::general_purpose::STANDARD.encode(&bytes[..bytes.len() / 2]); assert!(s.run("layer.addDataBytes", &json!({"fileName": name, "data": cut, "as": "scan"})).is_err(), "{name}"); std::fs::write(dir.join("cut"), &bytes[..bytes.len() / 2]).unwrap(); let cut_path = dir.join(format!("cut.{}", name.split('.').nth(1).unwrap())); std::fs::rename(dir.join("cut"), &cut_path).unwrap(); assert!(s.run("layer.addData", &json!({"path": cut_path.display().to_string(), "as": "scan"})).is_err(), "{name}"); } assert_eq!(s.map().layers.len(), count); assert!(s.run("layer.addData", &json!({"path": dir.join("map.tif").display().to_string(), "as": "pdf"})).is_err()); let _ = std::fs::remove_dir_all(&dir); } /// A scan never leaves with coordinates it doesn't have: it can't be exported, and the tools that /// would give their result a place on the ground refuse it; a GeoTIFF opened `as` a scan is one /// too, in the project as well. #[test] fn scans_have_no_coordinates_to_give_away() { let mut s = Session::new(Project::default()); let info = |s: &mut Session| s.run("raster.info", &json!({"layer": "utm"})).unwrap(); let r = s.run("layer.addDataBytes", &json!({"fileName": "utm.tif", "data": utm_tiff_b64()})).unwrap(); assert!(r.get("georeferenced").is_none()); assert_eq!(info(&mut s)["georeferenced"], true); s.run("raster.editPixels", &json!({"layer": "utm", "polygon": [[-81.5, 41.5], [-80.5, 41.5], [-81.0, 42.5]], "value": 1})).unwrap(); let r = s.run("layer.addDataBytes", &json!({"fileName": "utm.tif", "data": utm_tiff_b64(), "as": "scan", "name": "flat"})).unwrap(); assert_eq!(r["georeferenced"], false, "{r}"); let flat = |s: &mut Session| s.run("raster.info", &json!({"layer": "flat"})).unwrap(); assert_eq!(flat(&mut s)["georeferenced"], false); let dir = std::env::temp_dir().join(format!("rustgis-scan-export-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); s.active_layer = s.map().layer_by_name("flat").map(|l| l.id); let out = dir.join("flat.tif"); // A file left by an earlier, interrupted run would pass for one written now. let _ = std::fs::remove_file(&out); let e = s.run("share.exportRaster", &json!({"path": out.display().to_string()})).unwrap_err(); assert!(e.contains("georeference it first") && !out.exists(), "{e}"); let e = s.run("share.exportRaster", &json!({"path": out.display().to_string(), "cog": true})).unwrap_err(); assert!(e.contains("georeference it first") && !out.exists(), "as a COG: {e}"); let before = s.map().layer_by_name("flat").unwrap().raster.clone().unwrap().placement; let tools: [(&str, Value); 15] = [ ("raster.function", json!({"layer": "flat", "function": "hillshade"})), ("raster.project", json!({"layer": "flat", "crs": 32613})), ("raster.editPixels", json!({"layer": "flat", "polygon": [[0.0, 0.0], [0.1, 0.0], [0.0, 0.1]], "value": 1})), ("raster.process", json!({"layer": "flat", "operation": "resample", "factor": 2})), ("raster.process", json!({"layer": "flat", "operation": "clip"})), // Refused before the polygons are looked for, let alone placed on it. ("raster.process", json!({"layer": "flat", "operation": "clip", "polygon": "Nowhere"})), ("raster.mosaic", json!({"inputs": ["flat", "utm"]})), ("raster.change", json!({"before": "flat", "after": "flat"})), ("raster.classify", json!({"layer": "flat", "classes": 2})), ("raster.classifySupervised", json!({"layer": "flat", "training": "Nowhere", "classField": "c"})), ("raster.index", json!({"layer": "flat", "index": "ndvi", "red": 1, "nir": 1})), ("raster.measure", json!({"layer": "flat", "kind": "distance", "points": [[0.0, 0.0], [0.1, 0.1]]})), ("imagery.mesh", json!({"layer": "flat"})), ("dl.classify", json!({"layer": "flat", "training": "Nowhere", "classField": "c"})), ("dl.train", json!({"layer": "flat", "training": "Nowhere", "classField": "c"})), ]; let missed: Vec = tools .iter() .filter_map(|(cmd, q)| match s.run(cmd, q) { Err(e) if e.contains("no coordinates") => None, other => Some(format!("{cmd}: {other:?}")), }) .collect(); assert!(missed.is_empty(), "{missed:#?}"); let l = s.map().layer_by_name("flat").unwrap().raster.clone().unwrap(); assert_eq!(l.placement, before); assert!(!l.data.as_ref().unwrap().georeferenced, "still a scan"); // The project opens it as a scan, laid where it was, not as the GeoTIFF it came from. let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let again = t.map().layer_by_name("flat").unwrap().raster.clone().unwrap().data.unwrap(); assert!(!again.georeferenced); // Georeferencing it gives it coordinates, and then it exports. s.run("raster.georeference", &json!({"layer": "flat", "extent": [10.0, 40.0, 12.0, 41.0]})).unwrap(); assert!(s.run("share.exportRaster", &json!({"path": out.display().to_string()})).is_ok()); let _ = std::fs::remove_dir_all(&dir); } /// The PostGIS editing commands need a connection first, and a connection that isn't there fails /// as an error that never shows its password, leaving the session unconnected. #[cfg(not(target_arch = "wasm32"))] #[test] fn postgis_tab_needs_a_connection() { let mut s = Session::new(Project::default()); assert_eq!(s.run("pg.status", &json!({})).unwrap()["connected"], false); for (id, p) in [("pg.tables", json!({})), ("pg.addLayer", json!({"table": "roads"})), ("pg.disconnect", json!({}))] { let e = s.run(id, &p).unwrap_err(); assert!(e.contains("connect to a PostGIS database first"), "{id}: {e}"); } let before = s.map().layers.len(); let e = s.run("pg.connect", &json!({"host": "127.0.0.1", "port": 1, "database": "gis", "user": "me", "password": "s3cret"})).unwrap_err(); assert!(e.starts_with("gis: ") && !e.contains("s3cret"), "{e}"); let e = s.run("pg.connect", &json!({"connection": "postgresql://me:s3cret@127.0.0.1:1/gis"})).unwrap_err(); assert!(!e.contains("s3cret"), "{e}"); assert!(s.postgis.is_none()); assert_eq!(s.map().layers.len(), before); } /// A live PostGIS database through the PostGIS editing commands: `RUSTGIS_TEST_POSTGIS` names one /// (as for `postgis_live_end_to_end`) with a table that has an integer primary key. #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore] fn postgis_tab_live() { let Ok(dsn) = std::env::var("RUSTGIS_TEST_POSTGIS") else { return }; let mut s = Session::new(Project::default()); let dsn = dsn.trim_start_matches("postgres:").to_string(); let r = s.run("pg.connect", &json!({"connection": dsn})).unwrap(); assert_eq!(r["connected"], true); assert_eq!(s.run("pg.status", &json!({})).unwrap()["connected"], true); let listed = s.run("pg.tables", &json!({})).unwrap(); let names: Vec = listed["tables"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).map(str::to_string).collect(); assert!(!names.is_empty()); // The first table with an integer key loads whole, every row with its version. let r = names.iter().find_map(|n| s.run("pg.addLayer", &json!({"table": n, "limit": 500})).ok()).expect("a table with an integer key"); let id = r["layers"][0].as_u64().unwrap() as u32; let l = s.map().layer(id).unwrap(); assert!(!l.is_streamed() && !l.dataset.features.is_empty()); let pg = l.postgis.as_ref().unwrap(); assert_eq!(pg.versions.len(), l.dataset.features.len()); assert!(l.dataset.features.iter().all(|f| pg.versions.get(f.id).is_some())); // Saved with the project (never with a password), and read back. let text = serde_json::to_string(l).unwrap(); assert!(text.contains("\"versions\"") && !text.contains("password"), "{}", &text[..text.len().min(400)]); // A condition no row meets loads nothing, and a bad one is the server's error. let table = l.postgis.as_ref().unwrap().origin.table.clone(); assert!(s.run("pg.addLayer", &json!({"table": table, "where": "1 = 1; DROP TABLE x"})).unwrap_err().contains("without ';'")); assert!(s.run("pg.addLayer", &json!({"table": table, "where": "no_such_column > 1"})).is_err()); s.run("pg.disconnect", &json!({})).unwrap(); assert!(s.postgis.is_none()); } /// A layer loaded from PostGIS knows what changed in it, and saving refuses what it can't do /// before it reaches a database: no edits, a user who can't write, a layer not from PostGIS. #[cfg(not(target_arch = "wasm32"))] #[test] fn postgis_edits_are_counted_and_checked() { use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_map::source::{PostgisEdit, PostgisOrigin, RowVersion, row_hash}; let mut s = Session::new(Project::default()); let mut ds = Dataset::new("roads", GeometryType::Point, vec![Field::new("id", FieldType::Integer), Field::new("name", FieldType::Text)]); for (i, n) in ["a", "b", "c"].iter().enumerate() { ds.push_next(Some(Geometry::Point(Coord { x: i as f64, y: 0.0 })), vec![Value::Int(i as i64 + 1), Value::Text(n.to_string())]); } let versions = ds.features.iter().map(|f| (f.id, RowVersion { version: 7, hash: row_hash(f) })).collect(); let id = crate::cmd::layer::add_dataset(&mut s, ds); s.map_mut().layer_mut(id).unwrap().postgis = Some(PostgisEdit { origin: PostgisOrigin { database: "postgres:host=127.0.0.1 port=1 dbname=gis".into(), table: "roads".into(), family: None, crs: None, }, key: "id".into(), srid: 4326, writable: false, condition: None, limit: 0, versions, }); let p = json!({"layer": "roads"}); let r = s.run("pg.pendingEdits", &p).unwrap(); assert_eq!((r["inserts"].as_u64(), r["updates"].as_u64(), r["deletes"].as_u64()), (Some(0), Some(0), Some(0)), "{r}"); assert_eq!(s.run("pg.saveEdits", &p).unwrap()["saved"], false); // An edit, a deletion and a new feature, counted as such. s.map_mut().editing.settings.enabled = true; s.run("edit.setValue", &json!({"layer": "roads", "row": 0, "field": "name", "value": "aa"})).unwrap(); s.run( "edit.addFeature", &json!({"layer": "roads", "geometry": {"type": "Point", "coordinates": [5, 5]}, "attributes": {"name": "new"}}), ) .unwrap(); { let l = s.map_mut().layer_mut(id).unwrap(); l.dataset.features.remove(1); l.touch_geometry(); } let r = s.run("pg.pendingEdits", &p).unwrap(); assert_eq!((r["inserts"].as_u64(), r["updates"].as_u64(), r["deletes"].as_u64()), (Some(1), Some(1), Some(1)), "{r}"); // A user who can't write saves nothing; nor does a database that isn't there. let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.contains("can't insert, update or delete"), "{e}"); s.map_mut().layer_mut(id).unwrap().postgis.as_mut().unwrap().writable = true; let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.starts_with("gis: "), "{e}"); assert!(s.run("pg.discardEdits", &p).is_err()); // The edits are still there to save. assert_eq!(s.run("pg.pendingEdits", &p).unwrap()["updates"], 1); // Back on rows a save wrote over (undone past it): its edits, new rows too, aren't written // again, and no database is asked. let read = s.map().layer(id).unwrap().postgis.as_ref().unwrap().versions.clone(); s.saved_over.push(read.clone()); let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.contains("back before an edit saved to roads"), "{e}"); // Rows read again are a baseline of their own. s.map_mut().layer_mut(id).unwrap().postgis.as_mut().unwrap().versions = read.iter().collect(); let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.starts_with("gis: "), "{e}"); s.saved_over.clear(); // A layer not from PostGIS has no edits to save there. crate::cmd::layer::add_dataset(&mut s, Dataset::new("plain", GeometryType::Point, Vec::new())); let e = s.run("pg.saveEdits", &json!({"layer": "plain"})).unwrap_err(); assert!(e.contains("not a PostGIS layer loaded for editing"), "{e}"); } /// Edits saved to a live PostGIS database and read back, and every conflict refused: /// `RUSTGIS_TEST_POSTGIS` names a database (`postgresql://…`) where the test may make and drop /// two tables of its own. #[cfg(not(target_arch = "wasm32"))] #[test] #[ignore] fn postgis_edits_live() { /// Drops the test's tables, however the test ends. struct Cleanup(String, String, String); impl Drop for Cleanup { fn drop(&mut self) { if let Ok(mut c) = postgres::Client::connect(&self.0, postgres::NoTls) { let _ = c.batch_execute(&format!("DROP TABLE IF EXISTS public.{}; DROP TABLE IF EXISTS public.{}", self.1, self.2)); } } } let Ok(url) = std::env::var("RUSTGIS_TEST_POSTGIS") else { return }; let url = url.trim_start_matches("postgres:").to_string(); let mut db = postgres::Client::connect(&url, postgres::NoTls).unwrap(); let serial = format!("rustgis_edit_test_{}", std::process::id()); let keyed = format!("{serial}_k"); let _cleanup = Cleanup(url.clone(), serial.clone(), keyed.clone()); db.batch_execute(&format!( "CREATE TABLE public.{serial} (id serial PRIMARY KEY, name text, pop numeric(12,3), geom geometry(Polygon, 3857)); INSERT INTO public.{serial} (name, pop, geom) VALUES ('a', 12345.678, ST_Transform(ST_MakeEnvelope(10, 10, 10.1, 10.1, 4326), 3857)), ('b', 1.001, ST_Transform(ST_MakeEnvelope(11, 10, 11.1, 10.1, 4326), 3857)), ('c', 2.002, ST_Transform(ST_MakeEnvelope(12, 10, 12.1, 10.1, 4326), 3857)); CREATE TABLE public.{keyed} (id int PRIMARY KEY, name text, geom geometry(Point, 4326)); INSERT INTO public.{keyed} VALUES (1, 'one', ST_SetSRID(ST_MakePoint(1, 1), 4326)), (2, 'two', ST_SetSRID(ST_MakePoint(2, 2), 4326));" )) .unwrap(); let name_of = |db: &mut postgres::Client, id: i32| -> Option { db.query_opt(format!("SELECT name FROM public.{serial} WHERE id = $1").as_str(), &[&id]).unwrap().map(|r| r.get(0)) }; let mut s = Session::new(Project::default()); s.run("pg.connect", &json!({"connection": url})).unwrap(); let r = s.run("pg.addLayer", &json!({"table": format!("public.{serial}"), "name": "T"})).unwrap(); let id = r["layers"][0].as_u64().unwrap() as u32; s.map_mut().editing.settings.enabled = true; let row_of = |s: &Session, key: u64| s.map().layer(id).unwrap().dataset.features.iter().position(|f| f.id == key).unwrap(); // Rename row 1, delete row 3, add a polygon: saved in one go. let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "T", "row": r1, "field": "name", "value": "renamed"})).unwrap(); let r3 = row_of(&s, 3); s.map_mut().layer_mut(id).unwrap().selection = vec![r3]; s.active_layer = Some(id); s.run("edit.delete", &json!({})).unwrap(); s.run( "edit.addFeature", &json!({"layer": "T", "geometry": {"type": "Polygon", "coordinates": [[[13, 10], [13.1, 10], [13.1, 10.1], [13, 10.1], [13, 10]]]}, "attributes": {"name": "new", "pop": 4.5}}), ) .unwrap(); let r = s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap(); assert_eq!((r["inserted"].as_u64(), r["updated"].as_u64(), r["deleted"].as_u64()), (Some(1), Some(1), Some(1)), "{r}"); let new_key = r["keys"][0].as_i64().unwrap() as i32; // Undone, the save isn't made again (its new row inserted twice); redone, it is the layer // as saved. assert!(s.undo()); let e = s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap_err(); assert!(e.contains("back before an edit saved"), "{e}"); assert!(s.redo()); assert_eq!(s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap()["saved"], false); assert_eq!(name_of(&mut db, 1).as_deref(), Some("renamed")); assert_eq!(name_of(&mut db, 3), None); let row = db .query_one( format!( "SELECT name, pop::text, ST_GeometryType(geom), ST_SRID(geom), round(ST_XMin(ST_Transform(geom, 4326))::numeric, 6)::text FROM public.{serial} WHERE id = $1" ) .as_str(), &[&new_key], ) .unwrap(); assert_eq!( (row.get::<_, String>(0), row.get::<_, String>(1), row.get::<_, String>(2), row.get::<_, i32>(3), row.get::<_, String>(4)), ("new".into(), "4.500".into(), "ST_Polygon".into(), 3857, "13.000000".into()) ); // The numeric of a row whose name changed is as it was, to the last digit. let pop: String = db.query_one(format!("SELECT pop::text FROM public.{serial} WHERE id = 1").as_str(), &[]).unwrap().get(0); assert_eq!(pop, "12345.678"); // The new feature took its key; nothing is left to save. assert!(s.map().layer(id).unwrap().dataset.features.iter().any(|f| f.id == new_key as u64)); let r = s.run("pg.pendingEdits", &json!({"layer": "T"})).unwrap(); assert_eq!((r["inserts"].as_u64(), r["updates"].as_u64(), r["deletes"].as_u64()), (Some(0), Some(0), Some(0)), "{r}"); // Someone else renames row 1 while it is edited here: nothing is saved. db.execute(format!("UPDATE public.{serial} SET name = 'theirs' WHERE id = 1").as_str(), &[]).unwrap(); let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "T", "row": r1, "field": "name", "value": "mine"})).unwrap(); let e = s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap_err(); assert!(e.contains("row 1 was changed in the database"), "{e}"); assert_eq!(name_of(&mut db, 1).as_deref(), Some("theirs")); // Discarded: the rows as they are now; and a row someone else changed that isn't edited here // is no conflict. s.run("pg.discardEdits", &json!({"layer": "T"})).unwrap(); db.execute(format!("UPDATE public.{serial} SET name = 'theirs too' WHERE id = 2").as_str(), &[]).unwrap(); let r1 = row_of(&s, 1); assert_eq!(s.map().layer(id).unwrap().dataset.value(r1, 1), &rustgis_data::Value::Text("theirs".into())); s.run("edit.setValue", &json!({"layer": "T", "row": r1, "field": "name", "value": "mine"})).unwrap(); assert_eq!(s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap()["updated"], 1); assert_eq!((name_of(&mut db, 1).as_deref(), name_of(&mut db, 2).as_deref()), (Some("mine"), Some("theirs too"))); // A row someone else deleted can't be saved. db.execute(format!("DELETE FROM public.{serial} WHERE id = 1").as_str(), &[]).unwrap(); let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "T", "row": r1, "field": "name", "value": "gone"})).unwrap(); let e = s.run("pg.saveEdits", &json!({"layer": "T"})).unwrap_err(); assert!(e.contains("row 1 was deleted from the database"), "{e}"); // A table without a default key: a new row's key is its key field, which must be free. let r = s.run("pg.addLayer", &json!({"table": format!("public.{keyed}"), "name": "K"})).unwrap(); let kid = r["layers"][0].as_u64().unwrap() as u32; let point = |id: i64, name: &str| json!({"layer": "K", "geometry": {"type": "Point", "coordinates": [3, 3]}, "attributes": {"id": id, "name": name}}); s.run("edit.addFeature", &point(2, "taken")).unwrap(); let e = s.run("pg.saveEdits", &json!({"layer": "K"})).unwrap_err(); assert!(e.contains("a row with key 2 is already in the database"), "{e}"); let rows = s.map().layer(kid).unwrap().dataset.features.len(); s.map_mut().layer_mut(kid).unwrap().dataset.features.truncate(rows - 1); s.run("edit.addFeature", &point(7, "seven")).unwrap(); assert_eq!(s.run("pg.saveEdits", &json!({"layer": "K"})).unwrap()["keys"][0], 7); let n: i64 = db.query_one(format!("SELECT count(*) FROM public.{keyed} WHERE id = 7 AND name = 'seven'").as_str(), &[]).unwrap().get(0); assert_eq!(n, 1); } /// Edits go back to one local GeoPackage table atomically, without replacing the file or /// losing columns RustGIS doesn't edit. No external database or program is needed. #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_edits_save_back() { use rusqlite::functions::FunctionFlags; use rusqlite::types::ValueRef; use rustgis_data::raster::Crs; use rustgis_data::source::{Request, gpkg, open_path}; use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType}; struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { fn drop(&mut self) { // Also clean up if an assertion fails during the read-only check. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(self.0.join("roads.gpkg"), std::fs::Permissions::from_mode(0o600)); } let _ = std::fs::remove_dir_all(&self.0); } } let serial = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("rustgis-gpkg-edits-{}-{serial}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let _cleanup = Cleanup(dir.clone()); let path = dir.join("roads.gpkg"); let mut roads = Dataset::new("roads", GeometryType::Polyline, vec![Field::new("name", FieldType::Text), Field::new("lanes", FieldType::Integer)]); for (i, name) in ["a", "b", "c"].iter().enumerate() { let x = 1_000_000.0 + i as f64 * 10_000.0; roads.push( Some(Geometry::Line(vec![vec![Coord::new(x, 2_000_000.0), Coord::new(x + 1_000.0, 2_002_000.0)]])), vec![rustgis_data::Value::Text((*name).into()), rustgis_data::Value::Int(i as i64 + 1)], ); } gpkg::write_in(&roads, &path, Crs::from_epsg(3857).unwrap()).unwrap(); let db = rusqlite::Connection::open(&path).unwrap(); // Even attribute-only external UPDATEs compile the file's R-tree triggers. Supply their // functions on this independent connection too; these fixture blobs all have XY envelopes. let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS; db.create_scalar_function("ST_IsEmpty", 1, flags, |ctx| { Ok(match ctx.get_raw(0) { ValueRef::Blob(b) => b.get(3).map(|f| i64::from(f & 0x10 != 0)), _ => None, }) }) .unwrap(); for (name, offset) in [("ST_MinX", 8), ("ST_MaxX", 16), ("ST_MinY", 24), ("ST_MaxY", 32)] { db.create_scalar_function(name, 1, flags, move |ctx| { Ok(match ctx.get_raw(0) { ValueRef::Blob(b) => b.get(offset..offset + 8).and_then(|v| v.try_into().ok()).map(f64::from_le_bytes), _ => None, }) }) .unwrap(); } db.execute_batch( "ALTER TABLE roads ADD COLUMN photo BLOB; UPDATE roads SET photo = x'00ff' WHERE fid = 1; CREATE TABLE other (id INTEGER PRIMARY KEY, v TEXT); INSERT INTO other VALUES (1, 'keep'); CREATE TRIGGER no_bad BEFORE INSERT ON roads WHEN NEW.name = 'bad' BEGIN SELECT RAISE(ABORT, 'bad name'); END; UPDATE gpkg_contents SET last_change = '2000-01-01T00:00:00.000Z' WHERE table_name = 'roads';", ) .unwrap(); let name_of = |key: i64| -> String { db.query_row("SELECT name FROM roads WHERE fid = ?1", [key], |r| r.get(0)).unwrap() }; let count = || -> i64 { db.query_row("SELECT count(*) FROM roads", [], |r| r.get(0)).unwrap() }; let changed_at = || -> String { db.query_row("SELECT last_change FROM gpkg_contents WHERE table_name = 'roads'", [], |r| r.get(0)).unwrap() }; let counts = |s: &mut Session| { let r = s.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap(); (r["inserts"].as_u64().unwrap(), r["updates"].as_u64().unwrap(), r["deletes"].as_u64().unwrap()) }; let p = json!({"layer": "R"}); let mut s = Session::new(Project::default()); let loaded = s.run("gpkg.addLayer", &json!({"path": path, "table": "roads", "name": "R"})).unwrap(); assert_eq!((loaded["features"].as_u64(), loaded["matched"].as_u64(), loaded["skipped"].as_u64()), (Some(3), Some(3), Some(0))); assert_eq!(loaded["writable"], true); let id = loaded["layer"].as_u64().unwrap() as u32; let row_of = |s: &Session, key: u64| s.map().layer(id).unwrap().dataset.features.iter().position(|f| f.id == key).unwrap(); let ids: Vec<_> = s.map().layer(id).unwrap().dataset.features.iter().map(|f| f.id).collect(); assert_eq!(ids, vec![1, 2, 3]); assert_eq!(s.map().layer(id).unwrap().dataset.crs, Some(Crs::from_epsg(3857).unwrap())); assert_eq!(counts(&mut s), (0, 0, 0)); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); s.map_mut().editing.settings.enabled = true; let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "renamed"})).unwrap(); let moved = vec![Coord::new(15.0, 25.0), Coord::new(15.2, 25.3)]; { let r2 = row_of(&s, 2); let l = s.map_mut().layer_mut(id).unwrap(); l.dataset.features[r2].geometry = Some(Geometry::Line(vec![moved.clone()])); l.touch_geometry(); } let r3 = row_of(&s, 3); s.map_mut().layer_mut(id).unwrap().selection = vec![r3]; s.active_layer = Some(id); s.run("edit.delete", &json!({})).unwrap(); let new_line = |name: &str| { json!({"layer": "R", "geometry": {"type": "LineString", "coordinates": [[13, 22], [13.1, 22.1]]}, "attributes": {"name": name, "lanes": 4}}) }; s.run("edit.addFeature", &new_line("new")).unwrap(); // Deleting the highest loaded key must not make the new feature reuse its baseline id. let l = s.map().layer(id).unwrap(); assert_eq!(l.dataset.features.len(), 3); assert!(l.dataset.features.iter().any(|f| f.id > 3)); assert!(!l.dataset.features.iter().any(|f| f.id == 3)); assert_eq!(counts(&mut s), (1, 2, 1)); // Saving the project keeps pending edits, not database writes, and stores the file beside // it relatively. Reopening restores both the absolute source path and the baseline. let project = dir.join("edit.rgis"); s.run("project.saveAs", &json!({"path": project})).unwrap(); let saved: Project = serde_json::from_str(&std::fs::read_to_string(&project).unwrap()).unwrap(); assert!(std::path::Path::new(&saved.maps[0].layers[0].gpkg.as_ref().unwrap().path).is_relative()); assert_eq!(name_of(1), "a"); assert_eq!(count(), 3); { let mut reopened = Session::new(Project::default()); reopened.run("project.open", &json!({"path": project})).unwrap(); let edit = reopened.map().layer_by_name("R").unwrap().gpkg.as_ref().unwrap(); assert_eq!(std::fs::canonicalize(&edit.path).unwrap(), std::fs::canonicalize(&path).unwrap()); assert_eq!(counts(&mut reopened), (1, 2, 1)); } let before = changed_at(); let r = s.run("gpkg.saveEdits", &p).unwrap(); assert_eq!((r["inserted"].as_u64(), r["updated"].as_u64(), r["deleted"].as_u64()), (Some(1), Some(2), Some(1)), "{r}"); assert_eq!(r["keys"], json!([4])); assert!(s.map().layer(id).unwrap().dataset.features.iter().any(|f| f.id == 4)); assert_eq!(counts(&mut s), (0, 0, 0)); let row: (String, i64, Vec) = db.query_row("SELECT name, lanes, photo FROM roads WHERE fid = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap(); assert_eq!(row, ("renamed".into(), 1, vec![0, 255])); assert_eq!(db.query_row("SELECT count(*) FROM roads WHERE fid = 3", [], |r| r.get::<_, i64>(0)).unwrap(), 0); assert_eq!(name_of(4), "new"); assert_eq!(db.query_row("SELECT v FROM other WHERE id = 1", [], |r| r.get::<_, String>(0)).unwrap(), "keep"); assert_ne!(changed_at(), before); let native: Vec<_> = moved.iter().copied().map(rustgis_geom::lonlat_to_merc).collect(); let expected = rustgis_geom::Extent::from_points(native[0], native[1]); let indexed: (f64, f64, f64, f64) = db .query_row("SELECT minx, maxx, miny, maxy FROM rtree_roads_geom WHERE id = 2", [], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) }) .unwrap(); for (actual, wanted) in [(indexed.0, expected.min_x), (indexed.1, expected.max_x), (indexed.2, expected.min_y), (indexed.3, expected.max_y)] { // SQLite's R-tree stores outward-rounded float32 bounds. assert!((actual - wanted).abs() < wanted.abs() * 1e-6, "{actual} != {wanted}"); } assert_eq!(db.query_row("SELECT count(*) FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 3); let bounds: (f64, f64) = db.query_row("SELECT max_x, max_y FROM gpkg_contents WHERE table_name = 'roads'", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); assert!(bounds.0 >= expected.max_x && bounds.1 >= expected.max_y, "{bounds:?}"); { let source = open_path(&path, Some("roads")).unwrap(); assert_eq!(source.info().crs, "EPSG:3857"); let batch = source.read(&Request::all()).unwrap(); assert_eq!(batch.features.len(), 3); let f = batch.features.iter().find(|f| f.id == 2).unwrap(); let Some(Geometry::Line(parts)) = &f.geometry else { panic!("reopened line: {:?}", f.geometry) }; assert_eq!(parts.len(), 1); assert_eq!(parts[0].len(), moved.len()); for (a, b) in parts[0].iter().zip(&moved) { assert!((a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6, "{a:?} != {b:?}"); } } assert!(s.undo()); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("back before an edit saved"), "{e}"); assert!(s.redo()); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); // Fingerprints include unchanged attributes, not just the name edited in the layer. db.execute("UPDATE roads SET lanes = 9 WHERE fid = 1", []).unwrap(); let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "mine"})).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("row 1 was changed") && e.contains("roads.gpkg"), "{e}"); assert_eq!(name_of(1), "renamed"); assert_eq!(counts(&mut s), (0, 1, 0)); s.run("gpkg.discardEdits", &p).unwrap(); db.execute("DELETE FROM roads WHERE fid = 2", []).unwrap(); let r2 = row_of(&s, 2); s.run("edit.setValue", &json!({"layer": "R", "row": r2, "field": "name", "value": "gone"})).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("row 2 was deleted"), "{e}"); assert_eq!(counts(&mut s), (0, 1, 0)); // An insert rejected by a file-owned trigger rolls back an earlier UPDATE as well. s.run("gpkg.discardEdits", &p).unwrap(); let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "partial"})).unwrap(); s.run("edit.addFeature", &new_line("bad")).unwrap(); let before = changed_at(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("bad name"), "{e}"); assert_eq!(name_of(1), "renamed"); assert_eq!(count(), 2); assert_eq!(changed_at(), before); assert_eq!(counts(&mut s), (1, 1, 0)); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let original = std::fs::metadata(&path).unwrap().permissions(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap(); let result = s.run("gpkg.saveEdits", &p); std::fs::set_permissions(&path, original).unwrap(); let e = result.unwrap_err(); assert!(e.contains("read-only"), "{e}"); assert_eq!(counts(&mut s), (1, 1, 0)); } s.run("gpkg.discardEdits", &p).unwrap(); // A limit scopes the baseline: rows not loaded are never treated as deletions. let one = s.run("gpkg.addLayer", &json!({"path": path, "table": "roads", "limit": 1, "name": "One"})).unwrap(); assert_eq!((one["features"].as_u64(), one["matched"].as_u64()), (Some(1), Some(2))); s.run("edit.setValue", &json!({"layer": "One", "row": 0, "field": "name", "value": "limited"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "One"})).unwrap()["updated"], 1); assert_eq!(count(), 2); assert_eq!(name_of(4), "new"); assert_eq!(s.run("gpkg.discardEdits", &json!({"layer": "One"})).unwrap()["features"], 1); // Required Z and M dimensions forbid changed/new geometry, not attribute-only saves. for (sql, name) in [ ("UPDATE gpkg_geometry_columns SET z = 1, m = 0", "attributes with Z"), ("UPDATE gpkg_geometry_columns SET z = 0, m = 1", "attributes with M"), ] { db.execute(sql, []).unwrap(); s.run("gpkg.discardEdits", &p).unwrap(); let r1 = row_of(&s, 1); { let l = s.map_mut().layer_mut(id).unwrap(); l.dataset.features[r1].geometry = Some(Geometry::Line(vec![moved.clone()])); l.touch_geometry(); } let blob: Vec = db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("Z or M"), "{e}"); assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), blob); s.run("gpkg.discardEdits", &p).unwrap(); s.run("edit.addFeature", &new_line("needs dimensions")).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("Z or M"), "{e}"); assert_eq!(count(), 2); s.run("gpkg.discardEdits", &p).unwrap(); let r1 = row_of(&s, 1); s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": name})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); assert_eq!(name_of(1), name); assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), blob); } for _ in 0..2 { let discarded = s.run("gpkg.discardEdits", &p).unwrap(); assert_eq!((discarded["features"].as_u64(), discarded["matched"].as_u64()), (Some(2), Some(2))); assert_eq!(counts(&mut s), (0, 0, 0)); } assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); } #[cfg(not(target_arch = "wasm32"))] struct GpkgRegressionFixture { dir: std::path::PathBuf, } #[cfg(not(target_arch = "wasm32"))] impl GpkgRegressionFixture { fn new(name: &str) -> Self { let serial = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("rustgis-gpkg-{name}-{}-{serial}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let fixture = Self { dir }; let db = rusqlite::Connection::open(fixture.path()).unwrap(); // No AUTOINCREMENT or R-tree triggers: external deletion and column renames exercise // ordinary INTEGER PRIMARY KEY tables, not the export writer's schema. db.execute_batch( "CREATE TABLE gpkg_spatial_ref_sys (srs_name TEXT, srs_id INTEGER PRIMARY KEY, organization TEXT, organization_coordsys_id INTEGER, definition TEXT, description TEXT); INSERT INTO gpkg_spatial_ref_sys VALUES ('WGS 84', 4326, 'EPSG', 4326, '', ''); CREATE TABLE gpkg_contents (table_name TEXT PRIMARY KEY, data_type TEXT, identifier TEXT, description TEXT, last_change TEXT, min_x DOUBLE, min_y DOUBLE, max_x DOUBLE, max_y DOUBLE, srs_id INTEGER); INSERT INTO gpkg_contents (table_name, data_type, srs_id, last_change) VALUES ('roads', 'features', 4326, '2000-01-01T00:00:00.000Z'); CREATE TABLE gpkg_geometry_columns (table_name TEXT, column_name TEXT, geometry_type_name TEXT, srs_id INTEGER, z TINYINT, m TINYINT); INSERT INTO gpkg_geometry_columns VALUES ('roads', 'geom', 'POINT', 4326, 0, 0); CREATE TABLE roads (fid INTEGER PRIMARY KEY, geom BLOB, name TEXT, lanes INTEGER DEFAULT 2);", ) .unwrap(); for (key, name) in [(1, "a"), (2, "b"), (3, "c")] { db.execute( "INSERT INTO roads (fid, geom, name) VALUES (?1, ?2, ?3)", rusqlite::params![key, Self::point_blob(f64::from(key), 20.0), name], ) .unwrap(); } fixture } fn path(&self) -> std::path::PathBuf { self.dir.join("roads.gpkg") } fn point_blob(x: f64, y: f64) -> Vec { let mut blob = b"GP\x00\x03".to_vec(); blob.extend_from_slice(&4326i32.to_le_bytes()); for ordinate in [x, x, y, y] { blob.extend_from_slice(&ordinate.to_le_bytes()); } blob.push(1); blob.extend_from_slice(&1u32.to_le_bytes()); blob.extend_from_slice(&x.to_le_bytes()); blob.extend_from_slice(&y.to_le_bytes()); blob } fn session(&self) -> Session { let mut s = Session::new(Project::default()); let loaded = s.run("gpkg.addLayer", &json!({"path": self.path(), "table": "roads", "name": "R"})).unwrap(); assert_eq!(loaded["features"], 3); s.map_mut().editing.settings.enabled = true; s } } #[cfg(not(target_arch = "wasm32"))] impl Drop for GpkgRegressionFixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.dir); } } /// A streamed layer's export says how many of its source's rows couldn't be read, written a /// chunk at a time (GeoJSON) or from a whole read (KML) alike. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_streamed_export_counts_the_rows_it_could_not_read() { let fixture = GpkgRegressionFixture::new("unread-rows"); let path = fixture.path(); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch("INSERT INTO roads (fid, geom, name) VALUES (4, X'4750000300000000DEAD', 'bad');").unwrap(); drop(db); let mut s = Session::new(Project::default()); let loaded = s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap(); let id = loaded["layer"].as_u64().unwrap() as u32; assert!(s.map().layer(id).unwrap().is_streamed()); for ext in ["geojson", "kml"] { let mark = s.notice_mark(); let exported = s.run("share.exportLayer", &json!({"layer": id, "path": fixture.dir.join(format!("out.{ext}"))})).unwrap(); assert_eq!(exported["features"].as_u64(), Some(3), "{ext}"); let notices: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(notices.iter().any(|n| n.contains("1 rows couldn't be read")), "{ext}: {notices:?}"); } } /// A GeoPackage past the count limit is read whole within the memory budget, not refused or /// sampled (#1108, #1111), and exported a chunk at a time. #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_past_the_count_limit_streams_exports_and_reads_whole() { use rustgis_data::{Geometry, source::MAX_LIMIT}; let fixture = GpkgRegressionFixture::new("whole-read-limit"); let path = fixture.path(); let count = MAX_LIMIT + 1; let db = rusqlite::Connection::open(&path).unwrap(); // A single SQLite statement generates the rows without keeping millions of features in Rust. // The compact, valid point blob omits the optional envelope to keep this fixture small. let mut blob = b"GP\x00\x01".to_vec(); blob.extend_from_slice(&4326i32.to_le_bytes()); blob.extend(rustgis_data::wkb::encode(&Geometry::Point(rustgis_geom::Coord::new(1.0, 20.0)))); db.execute_batch( "DELETE FROM roads; UPDATE gpkg_contents SET min_x=1, max_x=1, min_y=20, max_y=20 WHERE table_name='roads';", ) .unwrap(); assert_eq!( db.execute( "WITH RECURSIVE rows(fid) AS (SELECT 1 UNION ALL SELECT fid+1 FROM rows WHERE fid = layer.dataset.features.iter().map(|f| f.id).collect(); keys.sort_unstable(); assert_eq!(keys, vec![1, 3]); let row = layer.dataset.features.iter().position(|f| f.id == 1).unwrap(); assert_eq!(layer.dataset.value(row, layer.dataset.field_index("name").unwrap()), &Value::Text("restored".into())); assert_eq!(layer.dataset.value(row, layer.dataset.field_index("lanes").unwrap()), &Value::Int(9)); assert_eq!(layer.dataset.features[row].geometry, Some(Geometry::Point(Coord::new(1.0, 20.0)))); assert_gpkg_regression_clean(&mut s); s.run("edit.setValue", &json!({"layer":"R","row":row,"field":"name","value":"next"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap()["updated"], 1); let stored: (String, i64) = db.query_row("SELECT name,lanes FROM roads WHERE fid=1", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); assert_eq!(stored, ("next".into(), 9)); assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 2); assert_gpkg_regression_clean(&mut s); let project = fixture.dir.join("restored.rgis"); s.run("project.saveAs", &json!({"path":project})).unwrap(); let mut reopened = Session::new(Project::default()); reopened.run("project.open", &json!({"path":project})).unwrap(); assert_gpkg_regression_clean(&mut reopened); assert_eq!(reopened.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap()["saved"], false); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_cross_row_triggers_refresh_residents_without_expanding_scope() { use rustgis_data::{Coord, Geometry, Value}; for deleted in [false, true] { let fixture = GpkgRegressionFixture::new("resident-triggers"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut s = fixture.session(); let p = json!({"layer":"R"}); s.run("edit.addField", &json!({"layer":"R","name":"local","type":"text"})).unwrap(); for row in 0..3 { s.run("edit.setValue", &json!({"layer":"R","row":row,"field":"local","value":format!("local {row}")})).unwrap(); } s.run("gpkg.saveEdits", &p).unwrap(); assert_gpkg_regression_clean(&mut s); s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"Other"})).unwrap(); let action = if deleted { "DELETE FROM roads WHERE fid=2;" } else { "UPDATE roads SET name='intermediate',lanes=8 WHERE fid=2; UPDATE roads SET name='trigger result',lanes=9,geom=(SELECT geom FROM roads WHERE fid=3) WHERE fid=2;" }; db.execute_batch(&format!( "CREATE TRIGGER affect_resident AFTER UPDATE OF name ON roads WHEN NEW.fid=1 AND NEW.name='save row1' BEGIN {action} INSERT INTO roads(fid,geom,name) VALUES(4,NEW.geom,'trigger only'); END;" )) .unwrap(); s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"save row1"})).unwrap(); s.run( "edit.addFeature", &json!({"layer":"R","geometry":{"type":"Point","coordinates":[10,20]}, "attributes":{"name":"synthetic","local":"new local"}}), ) .unwrap(); // A surviving selected row follows its identity when an earlier resident disappears. s.run("select.rows", &json!({"layer":"R","rows":[2]})).unwrap(); let saved = s.run("gpkg.saveEdits", &p).unwrap(); assert_eq!(saved["deleted"], 0, "trigger-only deletions are not submitted deletions"); let key = saved["keys"][0].as_u64().unwrap(); assert!(key > 4); let layer = s.map().layer_by_name("R").unwrap(); let ds = &layer.dataset; let row_of = |key| ds.features.iter().position(|f| f.id == key).unwrap(); assert!(!ds.features.iter().any(|f| f.id == 4), "trigger-only inserts must not replace a pending synthetic feature"); assert_eq!(ds.value(row_of(key), ds.field_index("name").unwrap()), &Value::Text("synthetic".into())); assert_eq!(ds.value(row_of(key), ds.field_index("local").unwrap()), &Value::Text("new local".into())); assert_eq!(layer.selection, vec![row_of(3)]); if deleted { assert!(!ds.features.iter().any(|f| f.id == 2)); assert_eq!(ds.features.len(), 3); } else { assert_eq!(ds.features.len(), 4); assert_eq!(ds.value(row_of(2), ds.field_index("name").unwrap()), &Value::Text("trigger result".into())); assert_eq!(ds.value(row_of(2), ds.field_index("lanes").unwrap()), &Value::Int(9)); assert_eq!(ds.value(row_of(2), ds.field_index("local").unwrap()), &Value::Text("local 1".into())); assert_eq!(ds.features[row_of(2)].geometry, Some(Geometry::Point(Coord::new(3.0, 20.0)))); } let other = &s.map().layer_by_name("Other").unwrap().dataset; assert_eq!(other.value(1, other.field_index("name").unwrap()), &Value::Text("b".into())); assert_eq!(other.features.len(), 3, "saving one copy must not refresh another"); let next_key = if deleted { 3 } else { 2 }; let next_row = row_of(next_key); assert_gpkg_regression_clean(&mut s); s.run("edit.setValue", &json!({"layer":"R","row":next_row,"field":"name","value":"next edit"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); let stored: (String, i64) = db.query_row("SELECT name,lanes FROM roads WHERE fid=?1", [next_key as i64], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); assert_eq!(stored, ("next edit".into(), if deleted { 2 } else { 9 })); assert_gpkg_regression_clean(&mut s); let project = fixture.dir.join("residents.rgis"); s.run("project.saveAs", &json!({"path":project})).unwrap(); let mut reopened = Session::new(Project::default()); reopened.run("project.open", &json!({"path":project})).unwrap(); assert_gpkg_regression_clean(&mut reopened); assert_eq!(reopened.run("gpkg.saveEdits", &p).unwrap()["saved"], false); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_trigger_snapshots_respect_partial_loaded_scope() { let fixture = GpkgRegressionFixture::new("partial-residents"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); db.execute_batch( "CREATE TRIGGER affect_unloaded AFTER UPDATE OF name ON roads WHEN NEW.fid=1 BEGIN UPDATE roads SET name='outside scope' WHERE fid=2; DELETE FROM roads WHERE fid=3; INSERT INTO roads(fid,name) VALUES(9,'trigger insert'); END;", ) .unwrap(); let mut s = Session::new(Project::default()); s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"R","limit":1})).unwrap(); s.map_mut().editing.settings.enabled = true; s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"limited"})).unwrap(); let saved = s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap(); assert_eq!(saved["deleted"], 0); let keys: Vec<_> = s.map().layer_by_name("R").unwrap().dataset.features.iter().map(|f| f.id).collect(); assert_eq!(keys, vec![1]); let stored: Vec<(i64, String)> = db .prepare("SELECT fid,name FROM roads ORDER BY fid") .unwrap() .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) .unwrap() .collect::>() .unwrap(); assert_eq!(stored, vec![(1, "limited".into()), (2, "outside scope".into()), (9, "trigger insert".into())]); assert_gpkg_regression_clean(&mut s); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_trigger_rebuild_rejects_a_valid_envelope_with_unreadable_wkb() { use rustgis_data::{Coord, Geometry}; let fixture = GpkgRegressionFixture::new("unreadable-enveloped-body"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut bad = GpkgRegressionFixture::point_blob(3.0, 20.0); bad.truncate(40); bad.push(0); db.execute("UPDATE roads SET geom=?1 WHERE fid=3", [&bad]).unwrap(); db.execute_batch( "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id,minx,maxx,miny,maxy); INSERT INTO rtree_roads_geom VALUES(1,1,1,20,20),(2,2,2,20,20),(3,3,3,20,20);", ) .unwrap(); let index = || { db.prepare("SELECT id,minx,maxx,miny,maxy FROM rtree_roads_geom ORDER BY id") .unwrap() .query_map([], |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, f64>(1)?, r.get::<_, f64>(2)?, r.get::<_, f64>(3)?, r.get::<_, f64>(4)?)) }) .unwrap() .collect::>>() .unwrap() }; let mut s = Session::new(Project::default()); let loaded = s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"R"})).unwrap(); assert_eq!((loaded["features"].as_u64(), loaded["skipped"].as_u64()), (Some(2), Some(1))); let id = loaded["layer"].as_u64().unwrap() as u32; // Ordinary saves leave the skipped row and its existing index entry untouched. s.map_mut().layer_mut(id).unwrap().dataset.features[0].geometry = Some(Geometry::Point(Coord::new(10.0, 20.0))); s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap(); assert_eq!(index().last(), Some(&(3, 3.0, 3.0, 20.0, 20.0))); db.execute_batch( "CREATE TRIGGER force_rebuild AFTER UPDATE OF name ON roads WHEN NEW.fid=1 BEGIN UPDATE roads SET lanes=9 WHERE fid=2; END;", ) .unwrap(); let before_index = index(); let before_revision: String = db.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get(0)).unwrap(); s.map_mut().editing.settings.enabled = true; s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"must roll back"})).unwrap(); let error = s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap_err(); assert!(error.contains("stored geometry can't be read"), "{error}"); assert_eq!(index(), before_index); assert_eq!(db.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), before_revision); assert_eq!(db.query_row("SELECT name FROM roads WHERE fid=1", [], |r| r.get::<_, String>(0)).unwrap(), "a"); assert_eq!(db.query_row("SELECT lanes FROM roads WHERE fid=2", [], |r| r.get::<_, i64>(0)).unwrap(), 2); assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid=3", [], |r| r.get::<_, Vec>(0)).unwrap(), bad); assert_eq!(s.run("gpkg.pendingEdits", &json!({"layer":"R"})).unwrap()["updates"], 1); } /// A template keeps no copies of the data: offline snapshots, archived edits and a streamed /// layer's file would each bring features back when it opens. #[test] fn project_template_carries_no_data() { let path = grid_fgb("template-streamed", 20); let mut s = with_streamed(&path); s.run("offline.download", &json!({"name": "Paris", "extent": [1.0, 48.0, 4.0, 50.0]})).unwrap(); s.run("data.archive", &json!({"layer": "Cities"})).unwrap(); s.run("edit.setValue", &json!({"layer": "Cities", "row": 0, "field": "name", "value": "Renamed"})).unwrap(); s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 45], [10, 55]]})).unwrap(); let mut table = rustgis_data::Dataset::new( "Visits", rustgis_data::GeometryType::None, vec![rustgis_data::Field::new("n", rustgis_data::FieldType::Integer)], ); table.push(None, vec![rustgis_data::Value::Int(1)]); table.source = Some(path.display().to_string()); s.project.add_table(table); assert!(!s.project.offline_areas.is_empty()); let offline = s.project.offline_areas.len(); let archived: usize = s .project .maps .iter() .chain(s.project.offline_areas.iter().map(|area| &area.data)) .flat_map(|map| &map.layers) .filter_map(|layer| layer.design.archive.as_ref()) .map(Vec::len) .sum(); assert!(archived > 0, "the fixture has archived edits to omit"); let before = serde_json::to_value(&s.project).unwrap(); let mark = s.notice_mark(); let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "template", &Value::Null).unwrap(); assert_eq!(serde_json::to_value(&s.project).unwrap(), before, "template export preserves the live project"); // The streamed layer has both its reload source and record-only provenance; the table // has its own provenance. All three owning references are omitted, independently of rows. for (kind, count) in [("offline area(s)", offline), ("archived edit record(s)", archived), ("data source reference(s)", 3)] { let expected = format!("{count} {kind} left out of the template, which keeps no features"); let notices: Vec<_> = s.notices_since(mark).iter().filter(|notice| notice.text.contains(kind)).map(|notice| notice.text.as_str()).collect(); assert_eq!(notices, [expected.as_str()], "one exact omission count for {kind}"); } let text = String::from_utf8(bytes).unwrap(); assert!(!text.contains(path.to_str().unwrap()), "the streamed layer's file is in the template"); let t: Project = serde_json::from_str(&text).unwrap(); assert!(t.offline_areas.is_empty()); assert!(t.tables.iter().all(|t| t.dataset.features.is_empty() && t.dataset.source.is_none())); assert!(t.maps.iter().flat_map(|m| &m.layers).all(|l| l.dataset.features.is_empty()), "graphics and features stay out"); let cities = t.maps[0].layer_by_name("Cities").unwrap(); assert_eq!(cities.design.archive.as_ref().map(Vec::len), Some(0), "archiving stays on, without its edits"); let streamed = t.maps[0].layer_by_name("Streamed").unwrap(); assert!(streamed.source.is_none() && streamed.dataset.features.is_empty()); let mut opened = Session::new(Project::default()); opened.run("project.fromTemplate", &json!({"text": text, "name": "Setup"})).unwrap(); assert!(opened.project.maps.iter().flat_map(|m| &m.layers).all(|l| l.dataset.features.is_empty())); let mut clean = Session::new(Project::default()); let mark = clean.notice_mark(); crate::cmd::items::item_bytes(&mut clean, "template", &Value::Null).unwrap(); assert!(clean.notices_since(mark).is_empty(), "a clean template has no omissions: {:?}", clean.notices_since(mark)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_template_drops_edit_binding() { let fixture = GpkgRegressionFixture::new("template"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let rows = || { db.prepare("SELECT fid, name, lanes, geom FROM roads ORDER BY fid") .unwrap() .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec>(3)?))) .unwrap() .collect::, _>>() .unwrap() }; let before = rows(); let mut s = fixture.session(); let template = fixture.dir.join("roads.rgistemplate"); let mark = s.notice_mark(); s.run("project.saveTemplate", &json!({"path": template})).unwrap(); let notices: Vec<_> = s .notices_since(mark) .iter() .filter(|notice| notice.text.contains("data source reference(s)")) .map(|notice| notice.text.as_str()) .collect(); assert_eq!(notices, ["2 data source reference(s) left out of the template, which keeps no features"]); let mut restored = Session::new(Project::default()); restored.run("project.fromTemplate", &json!({"path": template, "name": "Road setup"})).unwrap(); let layer = restored.map().layer_by_name("R").unwrap(); assert!(layer.dataset.features.is_empty()); assert!(layer.gpkg.is_none()); let error = restored.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap_err(); assert!(error.contains("not a GeoPackage layer loaded for editing"), "{error}"); assert_eq!(rows(), before, "saving a feature-less template must not delete or modify its original source"); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_stale_project_insert_replay_is_refused() { for undo_before_project_save in [false, true] { let fixture = GpkgRegressionFixture::new("stale-insert"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut s = fixture.session(); let project = fixture.dir.join("pending.rgis"); s.run( "edit.addFeature", &json!({ "layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": "one new row"} }), ) .unwrap(); if undo_before_project_save { s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap(); assert!(s.undo()); s.run("project.saveAs", &json!({"path": project})).unwrap(); } else { s.run("project.saveAs", &json!({"path": project})).unwrap(); // Pending inserts from a project are safe before their source revision changes. let mut fresh = Session::new(Project::default()); fresh.run("project.open", &json!({"path": project})).unwrap(); assert_eq!(fresh.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); } let mut reopened = Session::new(Project::default()); reopened.run("project.open", &json!({"path": project})).unwrap(); assert!(reopened.run("gpkg.saveEdits", &json!({"layer": "R"})).is_err()); assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 4); assert_eq!(reopened.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap()["inserts"], 1); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_project_requires_a_baseline_but_allows_an_empty_loaded_table() { let fixture = GpkgRegressionFixture::new("required-baseline"); let mut s = fixture.session(); let project = fixture.dir.join("missing.rgis"); s.run("project.saveAs", &json!({"path": project})).unwrap(); let mut saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&project).unwrap()).unwrap(); saved["maps"][0]["layers"][0]["gpkg"].as_object_mut().unwrap().remove("versions"); std::fs::write(&project, serde_json::to_vec(&saved).unwrap()).unwrap(); assert!(Session::new(Project::default()).run("project.open", &json!({"path": project})).is_err()); let db = rusqlite::Connection::open(fixture.path()).unwrap(); assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 3); db.execute("DELETE FROM roads", []).unwrap(); let mut empty = Session::new(Project::default()); empty.run("gpkg.addLayer", &json!({"path": fixture.path(), "table": "roads", "name": "R"})).unwrap(); empty.map_mut().editing.settings.enabled = true; empty .run( "edit.addFeature", &json!({ "layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": "first"} }), ) .unwrap(); empty.run("project.saveAs", &json!({"path": project})).unwrap(); let mut reopened = Session::new(Project::default()); reopened.run("project.open", &json!({"path": project})).unwrap(); assert_eq!(reopened.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); assert_eq!(db.query_row("SELECT name FROM roads", [], |r| r.get::<_, String>(0)).unwrap(), "first"); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_srs_change_refuses_attribute_save_until_discard() { let fixture = GpkgRegressionFixture::new("changed-srs"); let mut s = fixture.session(); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let before: Vec = db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap(); db.execute("UPDATE gpkg_geometry_columns SET srs_id = 3857", []).unwrap(); s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "pending"})).unwrap(); assert!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).is_err()); assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), before); assert_eq!(db.query_row("SELECT name FROM roads WHERE fid = 1", [], |r| r.get::<_, String>(0)).unwrap(), "a"); assert_eq!(s.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap()["updates"], 1); s.run("gpkg.discardEdits", &json!({"layer": "R"})).unwrap(); s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "after reload"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["updated"], 1); assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), before); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_discard_refreshes_geometry_family_and_visible_point_symbols() { let fixture = GpkgRegressionFixture::new("changed-geometry-family"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); db.execute_batch("UPDATE gpkg_geometry_columns SET geometry_type_name = 'LINESTRING'; UPDATE roads SET geom = NULL;").unwrap(); let mut s = fixture.session(); db.execute("UPDATE gpkg_geometry_columns SET geometry_type_name = 'POINT'", []).unwrap(); db.execute("UPDATE roads SET geom = ?1 WHERE fid = 1", [GpkgRegressionFixture::point_blob(10.0, 20.0)]).unwrap(); s.run("gpkg.discardEdits", &json!({"layer": "R"})).unwrap(); let layer = s.map().layer_by_name("R").unwrap(); assert_eq!(layer.geometry_type(), rustgis_data::GeometryType::Point); assert!(layer.renderer.base_symbol().size > 0.0, "reloaded point features need visible markers"); s.run( "edit.addFeature", &json!({ "layer": "R", "geometry": {"type": "Point", "coordinates": [11, 21]}, "attributes": {"name": "new point"} }), ) .unwrap(); assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); let stored: Vec = db.query_row("SELECT geom FROM roads WHERE name = 'new point'", [], |r| r.get(0)).unwrap(); assert_eq!(rustgis_data::wkb::decode(&stored[40..]), Some(rustgis_data::Geometry::Point(rustgis_data::Coord::new(11.0, 21.0)))); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_saved_defaults_and_trigger_values_become_baseline() { use rustgis_data::source::{Request, open_path}; use rustgis_data::{Coord, Geometry, Value}; let fixture = GpkgRegressionFixture::new("stored-values"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut s = fixture.session(); let p = json!({"layer": "R"}); let add = |name: &str| json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": name}}); let row_of = |s: &Session, key: u64| s.map().layer_by_name("R").unwrap().dataset.features.iter().position(|f| f.id == key).unwrap(); let lanes_of = |s: &Session, key: u64| { let dataset = &s.map().layer_by_name("R").unwrap().dataset; dataset.value(row_of(s, key), dataset.field_index("lanes").unwrap()).clone() }; let name_of = |s: &Session, key: u64| { let dataset = &s.map().layer_by_name("R").unwrap().dataset; dataset.value(row_of(s, key), dataset.field_index("name").unwrap()).clone() }; let geometry_of = |s: &Session, key: u64| s.map().layer_by_name("R").unwrap().dataset.features[row_of(s, key)].geometry.clone(); let stored_lanes = |key: u64| -> i64 { db.query_row("SELECT lanes FROM roads WHERE fid = ?1", [key as i64], |r| r.get(0)).unwrap() }; s.run("edit.addFeature", &add("default")).unwrap(); let saved = s.run("gpkg.saveEdits", &p).unwrap(); let default_key = saved["keys"][0].as_u64().unwrap(); assert_eq!(lanes_of(&s, default_key), Value::Int(2), "omitted attributes must reflect SQLite defaults after saving"); assert_eq!(stored_lanes(default_key), 2); assert_gpkg_regression_clean(&mut s); let row = row_of(&s, default_key); s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "default renamed"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); assert_eq!(stored_lanes(default_key), 2, "a later name edit must not replace the stored default with NULL"); assert_gpkg_regression_clean(&mut s); db.execute_batch("CREATE TABLE normalized_shapes (kind TEXT PRIMARY KEY, geom BLOB);").unwrap(); for (kind, x, y) in [("insert", 30.0, 40.0), ("update", 31.0, 41.0)] { db.execute("INSERT INTO normalized_shapes VALUES (?1, ?2)", rusqlite::params![kind, GpkgRegressionFixture::point_blob(x, y)]) .unwrap(); } db.execute_batch( "CREATE TRIGGER normalize_insert AFTER INSERT ON roads BEGIN UPDATE roads SET name = upper(trim(NEW.name)), lanes = 7, geom = (SELECT geom FROM normalized_shapes WHERE kind = 'insert') WHERE fid = NEW.fid; END; CREATE TRIGGER normalize_update AFTER UPDATE OF name ON roads WHEN NEW.name != upper(trim(NEW.name)) BEGIN UPDATE roads SET name = upper(trim(NEW.name)), lanes = NEW.lanes + 1, geom = (SELECT geom FROM normalized_shapes WHERE kind = 'update') WHERE fid = NEW.fid; END;", ) .unwrap(); s.run("edit.addFeature", &add(" inserted ")).unwrap(); let key = s.run("gpkg.saveEdits", &p).unwrap()["keys"][0].as_u64().unwrap(); assert_eq!(name_of(&s, key), Value::Text("INSERTED".into())); assert_eq!(lanes_of(&s, key), Value::Int(7)); assert_eq!(geometry_of(&s, key), Some(Geometry::Point(Coord::new(30.0, 40.0)))); assert_gpkg_regression_clean(&mut s); let row = row_of(&s, key); s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": " updated "})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); assert_eq!(name_of(&s, key), Value::Text("UPDATED".into())); assert_eq!(lanes_of(&s, key), Value::Int(8)); assert_eq!(geometry_of(&s, key), Some(Geometry::Point(Coord::new(31.0, 41.0)))); assert_gpkg_regression_clean(&mut s); let row = row_of(&s, key); s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "NEXT"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); let stored: (String, i64) = db.query_row("SELECT name, lanes FROM roads WHERE fid = ?1", [key as i64], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); assert_eq!(stored, ("NEXT".into(), 8), "an ordinary later edit must retain trigger-normalized attributes"); let batch = open_path(&fixture.path(), Some("roads")).unwrap().read(&Request::all()).unwrap(); assert_eq!( batch.features.iter().find(|f| f.id == key).unwrap().geometry, Some(Geometry::Point(Coord::new(31.0, 41.0))), "an ordinary later edit must retain trigger-normalized geometry", ); assert_gpkg_regression_clean(&mut s); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_insert_reserves_externally_deleted_loaded_keys() { use rustgis_data::Value; let fixture = GpkgRegressionFixture::new("reserved-keys"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut s = fixture.session(); let p = json!({"layer": "R"}); db.execute("DELETE FROM roads WHERE fid = 3", []).unwrap(); s.run("edit.addFeature", &json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": "new"}})) .unwrap(); let saved = s.run("gpkg.saveEdits", &p).unwrap(); let key = saved["keys"][0].as_u64().unwrap(); assert!(key > 3, "a newly committed row must not alias the externally deleted loaded key: {saved}"); { let dataset = &s.map().layer_by_name("R").unwrap().dataset; assert_eq!(dataset.features.len(), 4); let ids: std::collections::HashSet<_> = dataset.features.iter().map(|f| f.id).collect(); assert_eq!(ids.len(), 4, "the unchanged stale feature and inserted feature must remain distinguishable"); let name = dataset.field_index("name").unwrap(); assert_eq!(dataset.features.iter().find(|f| f.id == 3).unwrap().attrs[name], Value::Text("c".into())); assert_eq!(dataset.features.iter().find(|f| f.id == key).unwrap().attrs[name], Value::Text("new".into())); } assert_gpkg_regression_clean(&mut s); for _ in 0..2 { assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false, "saving again must not insert a phantom duplicate"); } let rows: Vec<(i64, String)> = db .prepare("SELECT fid, name FROM roads ORDER BY fid") .unwrap() .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) .unwrap() .collect::>() .unwrap(); assert_eq!(rows, vec![(1, "a".into()), (2, "b".into()), (key as i64, "new".into())]); assert_eq!(s.run("gpkg.discardEdits", &p).unwrap()["features"], 3); let features = &s.map().layer_by_name("R").unwrap().dataset.features; assert!(!features.iter().any(|f| f.id == 3)); assert!(features.iter().any(|f| f.id == key)); assert_gpkg_regression_clean(&mut s); } /// The declaration is compared with the one loaded, not with a table reopened just before saving. #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_command_save_refuses_a_declaration_changed_since_loading() { for (mutation, label) in [ ("UPDATE gpkg_geometry_columns SET geometry_type_name = 'MULTIPOINT'", "type"), ("UPDATE gpkg_geometry_columns SET z = 1", "z"), ("UPDATE gpkg_geometry_columns SET m = 1", "m"), ] { let fixture = GpkgRegressionFixture::new(&format!("declaration-{label}")); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let name_of = || -> String { db.query_row("SELECT name FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap() }; let mut s = fixture.session(); let p = json!({"layer": "R"}); db.execute(mutation, []).unwrap(); s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "must not save"})).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("changed shape"), "{label}: {e}"); assert_eq!(name_of(), "a", "{label}"); // Discard reloads the current declaration, after which attribute saves work again. s.run("gpkg.discardEdits", &p).unwrap(); s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "after reload"})).unwrap(); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1, "{label}"); assert_eq!(name_of(), "after reload", "{label}"); } } /// Reopened projects keep exact coordinates (no edits appear, nothing is rewritten), and the /// globe locator's simplified sketches can't be saved over the source. #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_reopened_polygons_stay_clean_and_locator_sketches_are_unbound() { use rustgis_data::raster::Crs; use rustgis_data::source::gpkg; use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; let fixture = GpkgRegressionFixture::new("locator-sketch"); let path = fixture.dir.join("parcels.gpkg"); let mut data = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("name", FieldType::Text)]); for k in 1..=3 { let centre = 10.0 * f64::from(k); // Many irregular vertices: coordinates whose shortest decimal form needs exact parsing. let ring = (0..=64) .map(|i| { let j = i % 64; let a = std::f64::consts::TAU * f64::from(j) / 64.0; Coord::new(centre + 5.0 * a.cos() + 0.01 * f64::from(j % 3), centre + 5.0 * a.sin()) }) .collect(); data.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Value::Text(format!("p{k}"))]); } gpkg::write_in(&data, &path, Crs::LonLat).unwrap(); let db = rusqlite::Connection::open(&path).unwrap(); let blobs = || -> Vec> { db.prepare("SELECT geom FROM parcels ORDER BY fid").unwrap().query_map([], |r| r.get(0)).unwrap().collect::>().unwrap() }; let before = blobs(); let mut s = Session::new(Project::default()); s.run("gpkg.addLayer", &json!({"path": path, "table": "parcels", "name": "P"})).unwrap(); let project = fixture.dir.join("parcels.rgis"); s.run("project.saveAs", &json!({"path": project})).unwrap(); let mut s = Session::new(Project::default()); s.run("project.open", &json!({"path": project})).unwrap(); s.map_mut().editing.settings.enabled = true; let p = json!({"layer": "P"}); let pending = s.run("gpkg.pendingEdits", &p).unwrap(); assert_eq!((pending["inserts"].as_u64(), pending["updates"].as_u64(), pending["deletes"].as_u64()), (Some(0), Some(0), Some(0))); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); assert_eq!(blobs(), before, "reopening a project must not rewrite untouched rows"); let locator = s.run("layout.publication", &json!({"locator": "globe"})).unwrap()["locator"].as_str().unwrap().to_string(); let sketch = s.project.maps.iter().find(|m| m.name == locator).unwrap(); let layer = sketch.layer_by_name("P").unwrap(); assert!(layer.gpkg.is_none() && layer.postgis.is_none(), "a simplified sketch must not keep the source binding"); s.run("map.activate", &json!({"name": locator})).unwrap(); let e = s.run("gpkg.saveEdits", &p).unwrap_err(); assert!(e.contains("not a GeoPackage layer loaded for editing"), "{e}"); assert_eq!(blobs(), before); } /// A layer bound to both sources (a hand-made project) reserves both baselines' keys. #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_new_features_avoid_keys_of_either_bound_baseline() { let fixture = GpkgRegressionFixture::new("dual-binding"); let mut s = fixture.session(); let id = s.map().layer_by_name("R").unwrap().id; s.map_mut().layer_mut(id).unwrap().postgis = Some(rustgis_map::source::PostgisEdit { origin: rustgis_map::source::PostgisOrigin { database: "postgres:host=127.0.0.1 port=1 dbname=gis".into(), table: "roads".into(), family: None, crs: None, }, key: "fid".into(), srid: 4326, writable: false, condition: None, limit: 0, versions: Default::default(), }); let r3 = s.map().layer(id).unwrap().dataset.features.iter().position(|f| f.id == 3).unwrap(); s.map_mut().layer_mut(id).unwrap().selection = vec![r3]; s.active_layer = Some(id); s.run("edit.delete", &json!({})).unwrap(); s.run("edit.addFeature", &json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}})).unwrap(); let ids: Vec = s.map().layer(id).unwrap().dataset.features.iter().map(|f| f.id).collect(); assert!(!ids.contains(&3), "a new feature must not take the GeoPackage key it deleted: {ids:?}"); assert!(ids.iter().any(|&k| k > 3), "{ids:?}"); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geopackage_discard_refreshes_renamed_key_and_geometry() { use rustgis_data::source::{Request, open_path}; use rustgis_data::{Coord, Geometry}; let fixture = GpkgRegressionFixture::new("renamed-columns"); let db = rusqlite::Connection::open(fixture.path()).unwrap(); let mut s = fixture.session(); let p = json!({"layer": "R"}); db.execute_batch( "ALTER TABLE roads RENAME COLUMN fid TO id; ALTER TABLE roads RENAME COLUMN geom TO shape; UPDATE gpkg_geometry_columns SET column_name = 'shape' WHERE table_name = 'roads';", ) .unwrap(); assert_eq!(s.run("gpkg.discardEdits", &p).unwrap()["features"], 3); let layer = s.map().layer_by_name("R").unwrap(); let binding = layer.gpkg.as_ref().unwrap(); assert_eq!(binding.key, "id"); assert_eq!(binding.geometry, "shape"); let id = layer.id; let row = layer.dataset.features.iter().position(|f| f.id == 1).unwrap(); s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "renamed columns work"})).unwrap(); let moved = Geometry::Point(Coord::new(15.0, 25.0)); { let layer = s.map_mut().layer_mut(id).unwrap(); layer.dataset.features[row].geometry = Some(moved.clone()); layer.touch_geometry(); } assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); assert_eq!(db.query_row("SELECT name FROM roads WHERE id = 1", [], |r| r.get::<_, String>(0)).unwrap(), "renamed columns work"); let batch = open_path(&fixture.path(), Some("roads")).unwrap().read(&Request::all()).unwrap(); assert_eq!(batch.features.iter().find(|f| f.id == 1).unwrap().geometry, Some(moved)); assert_eq!(batch.features.len(), 3); assert_gpkg_regression_clean(&mut s); assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); } #[test] fn raster_tools_read_projected_zone_files_without_reprojecting_twice() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Dataset, Geometry, GeometryType, Value}; let dir = std::env::temp_dir().join(format!("rustgis-zonal-crs-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: 622_000.0, origin_y: 5_164_000.0, pixel_width: 100.0, pixel_height: -100.0 }; let tr = Crs::Epsg(25832).transformer().unwrap(); let mut zones = Dataset::new("zones", GeometryType::Polygon, vec![]); let ring = [(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0), (1.0, 1.0)] .into_iter() .map(|(x, y)| tr.to_lonlat(gt.to_world(x, y)).unwrap()) .collect(); zones.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, zones)); let nw = tr.to_lonlat(gt.to_world(0.0, 0.0)).unwrap(); let se = tr.to_lonlat(gt.to_world(4.0, 4.0)).unwrap(); let geographic = GeoTransform { origin_x: nw.x, origin_y: nw.y, pixel_width: (se.x - nw.x) / 4.0, pixel_height: (se.y - nw.y) / 4.0 }; for (name, transform, crs) in [("utm", gt, Crs::Epsg(25832)), ("geographic", geographic, Crs::LonLat)] { let raster = Raster::new(name, 4, 4, vec![(1..=16).map(|v| v as f32).collect()], transform, crs, None, ColorHint::Gray, "Float32").unwrap(); s.map_mut().add_raster(name, rustgis_map::RasterLayer::from_path(raster, format!("{name}.tif"))); } for epsg in [4326, 25832, 32632, 3003, 3857] { for ext in ["gpkg", "shp"] { let path = dir.join(format!("zones_{epsg}.{ext}")).display().to_string(); s.run("share.exportLayer", &json!({"layer": "zones", "path": path, "crs": format!("EPSG:{epsg}")})).unwrap(); let loaded = s.run("layer.addData", &json!({"path": path, "name": "loaded_zones"})).unwrap()["layer"].clone(); let input = s.map().layer(loaded.as_u64().unwrap() as u32).unwrap().dataset.clone(); assert_eq!(input.crs.map(|c| c.label()).unwrap_or_else(|| "EPSG:4326".into()), format!("EPSG:{epsg}"), "{ext}"); assert!(input.extent().contains(tr.to_lonlat(gt.to_world(2.0, 2.0)).unwrap()), "{epsg}, {ext}: {:?}", input.extent()); for raster in ["utm", "geographic"] { let depth = s.undo_depth(); s.run( "gp.run", &json!({"tool": "zonalStats", "params": {"raster": raster, "polygons": "loaded_zones", "output": "stats"}}), ) .unwrap(); let out = &s.map().layer_by_name("stats").unwrap().dataset; for (stat, expected) in [("count", Value::Int(4)), ("mean", Value::Float(8.5)), ("sum", Value::Float(34.0))] { let field = out.field_index(&format!("{raster}_{stat}")).unwrap(); assert_eq!(out.value(0, field), &expected, "EPSG:{epsg}, {ext}, {raster}, {stat}"); } assert_eq!(out.features[0].geometry, input.features[0].geometry); assert_eq!(out.crs, input.crs); assert_eq!(s.undo_depth(), depth + 1); assert!(s.undo()); assert!(s.map().layer_by_name("stats").is_none()); assert!(s.redo()); assert!(s.map().layer_by_name("stats").is_some()); assert!(s.undo()); s.run("raster.process", &json!({"layer": raster, "operation": "clip", "polygon": "loaded_zones", "name": "clipped"})) .unwrap(); let clipped = s.map().layer_by_name("clipped").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap(); assert_eq!((clipped.width, clipped.height, clipped.bands[0].stats.count), (2, 2, 4), "{epsg}, {ext}, {raster}"); assert_eq!(clipped.bands[0].data.iter().sum::(), 34.0, "{epsg}, {ext}, {raster}"); assert!(s.undo()); } s.run("layer.remove", &json!({"layer": loaded})).unwrap(); } } let _ = std::fs::remove_dir_all(dir); } #[test] fn gp_zonal_stats_end_to_end_and_undo() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::Coord; let mut s = world(); let (w, h) = (10u32, 10u32); let mut data = Vec::with_capacity((w * h) as usize); for row in 0..h { for col in 0..w { data.push((col * 10 + row) as f32); } } let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; let raster = Raster::new("dem", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let raster_layer = rustgis_map::RasterLayer::from_path(raster, "dem.tif"); s.map_mut().add_raster("dem", raster_layer); let mut poly_ds = Dataset::new("districts", GeometryType::Polygon, vec![Field::new("code", FieldType::Text)]); let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { Geometry::Polygon(vec![vec![vec![ Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0), ]]]) }; poly_ds.push(Some(sq(1.0, 7.0, 3.0, 9.0)), vec![Value::Text("D1".into())]); poly_ds.push(Some(sq(50.0, 50.0, 52.0, 52.0)), vec![Value::Text("D2".into())]); s.map_mut().add_layer(rustgis_map::Layer::new(0, poly_ds)); let layers_before = s.map().layers.len(); let res = s.run( "gp.zonalStats", &json!({ "raster": "dem", "polygons": "districts", "output": "zonal_out", }), ); assert!(res.is_ok(), "gp.zonalStats failed: {res:?}"); assert_eq!(s.map().layers.len(), layers_before + 1); let out_layer = s.map().layer_by_name("zonal_out").expect("output layer should exist"); assert_eq!(out_layer.dataset.features.len(), 2); let mean_idx = out_layer.dataset.field_index("dem_mean").expect("dem_mean field"); let count_idx = out_layer.dataset.field_index("dem_count").expect("dem_count field"); let min_idx = out_layer.dataset.field_index("dem_min").expect("dem_min field"); let max_idx = out_layer.dataset.field_index("dem_max").expect("dem_max field"); let sum_idx = out_layer.dataset.field_index("dem_sum").expect("dem_sum field"); let stddev_idx = out_layer.dataset.field_index("dem_stddev").expect("dem_stddev field"); assert_eq!(out_layer.dataset.value(0, 0), &Value::Text("D1".into())); assert_eq!(out_layer.dataset.value(0, count_idx), &Value::Int(4)); assert_eq!(out_layer.dataset.value(0, mean_idx), &Value::Float(16.5)); assert_eq!(out_layer.dataset.value(0, min_idx), &Value::Float(11.0)); assert_eq!(out_layer.dataset.value(0, max_idx), &Value::Float(22.0)); assert_eq!(out_layer.dataset.value(0, sum_idx), &Value::Float(66.0)); let std = out_layer.dataset.value(0, stddev_idx).as_f64().unwrap(); assert!((std - 25.25f64.sqrt()).abs() < 1e-10); assert_eq!(out_layer.dataset.value(1, 0), &Value::Text("D2".into())); assert_eq!(out_layer.dataset.value(1, count_idx), &Value::Int(0)); assert_eq!(out_layer.dataset.value(1, mean_idx), &Value::Null); assert_eq!(out_layer.dataset.value(1, min_idx), &Value::Null); assert_eq!(out_layer.dataset.value(1, max_idx), &Value::Null); assert_eq!(out_layer.dataset.value(1, sum_idx), &Value::Null); assert_eq!(out_layer.dataset.value(1, stddev_idx), &Value::Null); assert!(s.undo()); assert_eq!(s.map().layers.len(), layers_before); assert!(s.map().layer_by_name("zonal_out").is_none()); assert!(s.redo()); assert_eq!(s.map().layers.len(), layers_before + 1); assert!(s.map().layer_by_name("zonal_out").is_some()); let run_res = s.run( "gp.run", &json!({ "tool": "zonalStats", "params": { "raster": "dem", "polygons": "districts", "stats": ["mean", "count"], "output": "via_gp_run", } }), ); assert!(run_res.is_ok(), "gp.run with zonalStats failed: {run_res:?}"); let run_layer = s.map().layer_by_name("via_gp_run").expect("via_gp_run layer should exist"); assert!(run_layer.dataset.field_index("dem_mean").is_some()); assert!(run_layer.dataset.field_index("dem_count").is_some()); assert!(run_layer.dataset.field_index("dem_min").is_none()); // Loaded WebMercator data already uses longitude/latitude geometry. let mut merc_ds = Dataset::new("merc_districts", GeometryType::Polygon, vec![Field::new("code", FieldType::Text)]); merc_ds.crs = Some(Crs::WebMercator); merc_ds.push(Some(sq(1.0, 7.0, 3.0, 9.0)), vec![Value::Text("M1".into())]); s.map_mut().add_layer(rustgis_map::Layer::new(0, merc_ds)); let merc_res = s.run( "gp.zonalStats", &json!({ "raster": "dem", "polygons": "merc_districts", "output": "merc_out", "bands": [1.0], }), ); assert!(merc_res.is_ok(), "gp.zonalStats with WebMercator failed: {merc_res:?}"); let merc_layer = s.map().layer_by_name("merc_out").expect("merc_out should exist"); assert_eq!(merc_layer.dataset.crs, Some(Crs::WebMercator)); let m_count_idx = merc_layer.dataset.field_index("dem_count").unwrap(); let m_mean_idx = merc_layer.dataset.field_index("dem_mean").unwrap(); assert_eq!(merc_layer.dataset.value(0, m_count_idx), &Value::Int(4)); assert_eq!(merc_layer.dataset.value(0, m_mean_idx), &Value::Float(16.5)); let multiband = Raster::new( "multi", w, h, vec![vec![2.0; (w * h) as usize], vec![7.0; (w * h) as usize]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32", ) .unwrap(); s.map_mut().add_raster("multi", rustgis_map::RasterLayer::from_path(multiband, "multi.tif")); // "bands": "all" let all_res = s.run( "gp.zonalStats", &json!({ "raster": "multi", "polygons": "districts", "output": "all_bands_out", "bands": "all", }), ); assert!(all_res.is_ok(), "gp.zonalStats with bands: all failed: {all_res:?}"); assert!(s.run("gp.zonalStats", &json!({})).is_err()); assert!(s.run("gp.zonalStats", &json!({"raster": "nonexistent", "polygons": "districts"})).is_err()); let all_layer = s.map().layer_by_name("all_bands_out").unwrap(); for (field, expected) in [("multi_b1_mean", 2.0), ("multi_b2_mean", 7.0)] { let idx = all_layer.dataset.field_index(field).expect("all bands must be expanded"); assert_eq!(all_layer.dataset.value(0, idx), &Value::Float(expected)); } let error = s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "Cities"})).unwrap_err(); assert!(error.contains("needs polygon geometries"), "{error}"); for (band, fragment) in [ (json!(999), "raster has bands"), (json!(0), "positive"), (json!(-1), "positive"), (json!(1.5), "integers"), (json!(4294967297u64), "raster has bands"), ] { let error = s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [band]})).unwrap_err(); assert!(error.contains(fragment), "{error}"); } } #[test] fn raster_clip_polygon_selection_semantics_and_partial_failure() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::Coord; let mut s = world(); // 10x10 raster: origin (0.0, 10.0), cell size (1.0, -1.0) let (w, h) = (10u32, 10u32); let mut data = Vec::with_capacity((w * h) as usize); for row in 0..h { for col in 0..w { data.push((col * 10 + row) as f32); } } let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; let raster = Raster::new("dem", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let raster_layer = rustgis_map::RasterLayer::from_path(raster, "dem.tif"); s.map_mut().add_raster("dem", raster_layer); let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { Geometry::Polygon(vec![vec![vec![ Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0), ]]]) }; let mut poly_ds = Dataset::new("clip_zones", GeometryType::Polygon, vec![Field::new("name", FieldType::Text)]); // Feature 0: [1.0, 7.0] to [3.0, 9.0] poly_ds.push(Some(sq(1.0, 7.0, 3.0, 9.0)), vec![Value::Text("Z1".into())]); // Feature 1: [2.0, 6.0] to [4.0, 8.0] (overlaps with Z1) poly_ds.push(Some(sq(2.0, 6.0, 4.0, 8.0)), vec![Value::Text("Z2".into())]); // Feature 2: [6.0, 1.0] to [8.0, 3.0] (separate area) poly_ds.push(Some(sq(6.0, 1.0, 8.0, 3.0)), vec![Value::Text("Z3".into())]); // Feature 3: Degenerate hole (< 3 coords) -> should trigger rejection of this feature let bad_hole_poly = Geometry::Polygon(vec![vec![ vec![Coord::new(4.0, 4.0), Coord::new(6.0, 4.0), Coord::new(6.0, 6.0), Coord::new(4.0, 6.0), Coord::new(4.0, 4.0)], vec![Coord::new(4.5, 4.5), Coord::new(5.0, 5.0)], // invalid hole with only 2 points ]]); poly_ds.push(Some(bad_hole_poly), vec![Value::Text("Z4_bad".into())]); let poly_layer_id = s.map_mut().add_layer(rustgis_map::Layer::new(0, poly_ds)); // Test 1: selectedOnly: "invalid" or non-boolean returns validation error let err_type = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": "yes", }), ); assert!(err_type.is_err()); assert!(err_type.unwrap_err().contains("must be a boolean")); // Test 2: selectedOnly: true with empty selection returns error let err_empty_sel = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": true, }), ); assert!(err_empty_sel.is_err()); assert!(err_empty_sel.unwrap_err().contains("no features are selected")); // Test 3: selectedOnly: true with Feature 0 selected { let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); l.selection = vec![0]; } let res_sel = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": true, "name": "dem_sel_0", }), ); assert!(res_sel.is_ok(), "clip selected failed: {res_sel:?}"); let sel_layer = s.map().layer_by_name("dem_sel_0").expect("dem_sel_0 exists"); let sel_raster = sel_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); // Feature 0 covers x: [1.0, 3.0], y: [7.0, 9.0] -> 2 cols x 2 rows assert_eq!(sel_raster.width, 2); assert_eq!(sel_raster.height, 2); // Test 4: Overlapping polygons combined coverage (Features 0 and 1) { let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); l.selection = vec![0, 1]; } let res_overlap = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": true, "name": "dem_overlap", }), ); assert!(res_overlap.is_ok(), "clip overlap failed: {res_overlap:?}"); let ov_layer = s.map().layer_by_name("dem_overlap").expect("dem_overlap exists"); let ov_raster = ov_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); // Features 0 & 1 union bounding box: x: [1.0, 4.0], y: [6.0, 9.0] -> 3 cols x 3 rows assert_eq!(ov_raster.width, 3); assert_eq!(ov_raster.height, 3); // Test 5: selectedOnly: false ignores active selection and clips using all valid features let res_all = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": false, "name": "dem_all", }), ); assert!(res_all.is_ok(), "clip all failed: {res_all:?}"); let all_layer = s.map().layer_by_name("dem_all").expect("dem_all exists"); let all_raster = all_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); // Features 0, 1, 2 bounding box spans x: [1.0, 8.0], y: [1.0, 9.0] -> 7 cols x 8 rows assert_eq!(all_raster.width, 7); assert_eq!(all_raster.height, 8); // Test 6: Partial failure reporting: Feature 0 (valid) + Feature 3 (invalid hole) // Feature 3 should be skipped with a warning, Feature 0 should succeed { let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); l.selection = vec![0, 3]; } let res_partial = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": true, "name": "dem_partial", }), ); let partial_result = res_partial.expect("clip partial should succeed for valid feature"); let warning = partial_result["messages"][0].as_str().expect("partial failures must be visible in command output"); assert!(warning.contains("skipped 1 feature(s)"), "{warning}"); let partial_layer = s.map().layer_by_name("dem_partial").expect("dem_partial exists"); let partial_raster = partial_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); assert_eq!(partial_raster.width, 2); assert_eq!(partial_raster.height, 2); // Test 7: Total failure when all selected features fail validation (only Feature 3 selected) { let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); l.selection = vec![3]; } let res_bad = s.run( "raster.process", &json!({ "layer": "dem", "operation": "clip", "polygon": "clip_zones", "selectedOnly": true, "name": "dem_bad", }), ); assert!(res_bad.is_err()); let err_msg = res_bad.unwrap_err(); assert!(err_msg.contains("has no valid polygon geometries")); assert!(err_msg.contains("feature 4")); assert!(err_msg.contains("hole 1 is invalid")); } #[test] fn layer_selection_replaces_toggles_and_extends() { let mut s = world(); let name = |s: &Session, id: u32| s.map().layer(id).map(|l| l.name.clone()).unwrap_or_default(); let picked = |s: &Session| s.selected_layers().into_iter().map(|id| name(s, id)).collect::>(); let id = |s: &Session, n: &str| s.map().layer_by_name(n).map(|l| l.id).unwrap(); let depth = s.undo_depth(); s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); assert_eq!(picked(&s), ["Cities"]); // Toggled in, the layer becomes active; toggled out, another of the selection does. s.run("layer.select", &json!({"layer": "Lakes", "mode": "toggle"})).unwrap(); assert_eq!(picked(&s), ["Cities", "Lakes"]); assert_eq!(s.active_layer, Some(id(&s, "Lakes"))); s.run("layer.select", &json!({"layer": "Lakes", "mode": "toggle"})).unwrap(); assert_eq!(picked(&s), ["Cities"]); assert_eq!(s.active_layer, Some(id(&s, "Cities"))); // Extended by a list; the last of it is active. A plain select starts over. s.run("layer.select", &json!({"layer": ["Rivers", "Countries", "Rivers"], "mode": "extend"})).unwrap(); assert_eq!(picked(&s), ["Cities", "Rivers", "Countries"]); assert_eq!(s.active_layer, Some(id(&s, "Countries"))); s.run("layer.select", &json!({"layer": "Rivers"})).unwrap(); assert_eq!(picked(&s), ["Rivers"]); // Choosing layers is not an undo step. A bad layer, mode or list changes nothing. assert_eq!(s.undo_depth(), depth); s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); for p in [ json!({"layer": ["Cities", "Nope"]}), json!({"layer": [], "mode": "toggle"}), json!({"layer": [null]}), json!({"layer": [[1]]}), json!({"layer": "Cities", "mode": "sideways"}), ] { assert!(s.run("layer.select", &p).is_err(), "{p}"); } assert_eq!(picked(&s), ["Cities", "Lakes"]); // Setting the active layer on its own (adding data, a removal) leaves the selection behind. s.active_layer = Some(id(&s, "Rivers")); assert_eq!(picked(&s), ["Rivers"]); } #[test] fn layer_commands_act_on_the_whole_layer_selection() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); // Hidden and shown layers swap to one state: the opposite of the active layer's (the last // one listed, Rivers, hidden: all are shown). s.run("layer.visibility", &json!({"layer": "Rivers", "visible": false})).unwrap(); s.run("layer.select", &json!({"layer": ["Countries", "Rivers"]})).unwrap(); s.run("layer.visibility", &Value::Null).unwrap(); let shown = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().visible; assert!(shown(&s, "Rivers") && shown(&s, "Countries")); s.run("layer.visibility", &json!({"visible": false})).unwrap(); assert!(!shown(&s, "Rivers") && !shown(&s, "Countries") && shown(&s, "Cities")); // To the top and to the bottom, keeping their order, each one undo step. s.run("layer.move", &json!({"to": 0})).unwrap(); assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); s.run("layer.move", &json!({"to": 1e9})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); assert!(s.run("layer.move", &json!({"to": 1})).is_err(), "several layers can't go to the middle"); // "to" is where the topmost of them lands, as for one layer: 2 of 4 at 2 is the bottom. s.run("edit.undo", &Value::Null).unwrap(); s.run("layer.move", &json!({"to": 2})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); s.run("edit.undo", &Value::Null).unwrap(); s.run("layer.move", &json!({"to": 3})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); // Selectable on all of them. s.run("layer.selectable", &json!({"on": false})).unwrap(); assert!(!s.map().layer_by_name("Rivers").unwrap().selectable && !s.map().layer_by_name("Countries").unwrap().selectable); assert!(s.map().layer_by_name("Cities").unwrap().selectable); // Zoom to both: a view command, no undo step. let depth = s.undo_depth(); s.run("layer.zoomTo", &Value::Null).unwrap(); assert_eq!(s.undo_depth(), depth); // Removing: one undo step; the layer that takes the topmost one's place is active alone. s.run("layer.remove", &Value::Null).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes"]); assert_eq!(s.undo_depth(), depth + 1); assert_eq!(s.selected_layers(), vec![s.active_layer.unwrap()]); assert_eq!(s.active_layer, s.map().layer_by_name("Cities").map(|l| l.id)); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); // A list with an unknown layer removes nothing. assert!(s.run("layer.remove", &json!({"layer": ["Rivers", "Nope"]})).is_err()); assert_eq!(names(&s).len(), 4); } #[test] fn layer_selection_survives_a_failed_project_open() { let mut s = world(); s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); let before = s.selected_layers(); // Another project replaces this one and fails: the selection comes back with it. let _ = s.run("project.openText", &json!({"text": "not a project"})); assert_eq!(s.selected_layers(), before); } #[test] fn several_layers_move_to_the_top_with_one_in_a_group() { let mut s = world(); let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); s.run("group.add", &json!({"layers": ["Lakes", "Countries"]})).unwrap(); assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); s.run("layer.select", &json!({"layer": ["Rivers", "Countries"]})).unwrap(); // A layer out of a group goes to the map's top; one in a group, to its group's top. let depth = s.undo_depth(); s.run("layer.move", &json!({"to": 0})).unwrap(); assert_eq!(names(&s), ["Rivers", "Cities", "Countries", "Lakes"]); assert_eq!(s.undo_depth(), depth + 1); s.run("layer.move", &json!({"to": 1e9})).unwrap(); assert_eq!(names(&s), ["Cities", "Lakes", "Countries", "Rivers"]); let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); assert_eq!(group(&s, "Countries").as_deref(), Some("New Group")); } #[test] fn several_layers_go_into_and_out_of_a_group_in_one_step() { let mut s = world(); let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); s.run("group.add", &json!({"name": "Water", "layers": ["Lakes"]})).unwrap(); s.run("layer.select", &json!({"layer": ["Cities", "Rivers"]})).unwrap(); let depth = s.undo_depth(); s.run("layer.group", &json!({"group": "Water"})).unwrap(); assert_eq!(group(&s, "Cities").as_deref(), Some("Water")); assert_eq!(group(&s, "Rivers").as_deref(), Some("Water")); assert_eq!(s.undo_depth(), depth + 1); s.run("layer.group", &json!({"layer": ["Cities", "Rivers"], "group": null})).unwrap(); assert_eq!(group(&s, "Cities"), None); assert_eq!(group(&s, "Rivers"), None); assert_eq!(group(&s, "Lakes").as_deref(), Some("Water")); // "before" places one layer; an unknown one fails the whole list. assert!(s.run("layer.group", &json!({"layer": ["Cities", "Rivers"], "group": "Water", "before": "Lakes"})).is_err()); assert!(s.run("layer.group", &json!({"layer": ["Cities", "Nope"], "group": "Water"})).is_err()); assert_eq!(group(&s, "Cities"), None); } #[test] fn another_project_starts_with_no_layer_selection() { let mut s = world(); let text = serde_json::to_string(&s.project).unwrap(); let mut t = Session::new(Project::default()); t.run("project.openText", &json!({"text": text})).unwrap(); let opened_active = t.active_layer.and_then(|id| t.map().layer(id)).map(|l| l.name.clone()).unwrap(); // Picked around the layer the next project starts active on, with the same ids there. let other = if opened_active == "Lakes" { "Cities" } else { "Lakes" }; s.run("layer.select", &json!({"layer": [other, opened_active.as_str()]})).unwrap(); s.run("project.openText", &json!({"text": text})).unwrap(); assert!(s.selected_layers().len() <= 1, "{:?}", s.selected_layers()); } #[test] fn a_layer_selection_left_behind_stays_gone() { let mut s = world(); let id = |s: &Session, n: &str| s.map().layer_by_name(n).map(|l| l.id).unwrap(); s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); let lakes = id(&s, "Lakes"); // Another layer becomes active on its own (as adding data does), then Lakes again. s.active_layer = Some(id(&s, "Rivers")); s.run("layer.visibility", &json!({"layer": "Rivers", "visible": true})).unwrap(); s.active_layer = Some(lakes); assert_eq!(s.selected_layers(), vec![lakes]); } #[test] fn a_one_layer_list_names_that_layer_not_the_active_one() { let mut s = world(); let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); s.run("group.add", &json!({"name": "Water", "layers": ["Lakes"]})).unwrap(); s.run("layer.select", &json!({"layer": "Rivers"})).unwrap(); s.run("layer.group", &json!({"layer": ["Cities"], "group": "Water"})).unwrap(); assert_eq!(group(&s, "Cities").as_deref(), Some("Water")); assert_eq!(group(&s, "Rivers"), None); } #[test] fn a_layer_list_needs_no_active_layer() { let mut s = world(); s.active_layer = None; s.run("layer.visibility", &json!({"layer": ["Cities", "Rivers"], "visible": false})).unwrap(); s.run("layer.selectable", &json!({"layer": ["Cities"], "on": false})).unwrap(); assert!(!s.map().layer_by_name("Rivers").unwrap().visible); assert!(!s.map().layer_by_name("Cities").unwrap().selectable); } #[test] fn toggling_out_the_only_picked_layer_leaves_none_active() { let mut s = world(); s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); s.run("layer.select", &json!({"layer": "Cities", "mode": "toggle"})).unwrap(); assert_eq!(s.active_layer, None); assert!(s.selected_layers().is_empty()); assert!(s.run("layer.remove", &Value::Null).is_err(), "nothing picked, nothing removed"); } #[test] fn a_layout_exports_to_svg_with_each_element_its_own_group() { let mut s = world(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape"})).unwrap(); let title = s .run("layout.addElement", &json!({"kind": "text", "text": "Cities & rivers <2026>", "x": 20, "y": 10, "w": 150, "h": 15})) .unwrap()["element"] .as_u64() .unwrap(); s.run("layout.addElement", &json!({"kind": "rectangle", "x": 5, "y": 5, "w": 40, "h": 20})).unwrap(); let out = std::env::temp_dir().join(format!("rustgis-layout-{}.svg", std::process::id())); let mark = s.notice_mark(); let r = s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 72})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("as pictures, not vectors")), "nothing too large for vectors"); let svg = std::fs::read_to_string(&out).unwrap(); let _ = std::fs::remove_file(&out); assert_eq!(r["bytes"].as_u64(), Some(svg.len() as u64)); // A page in millimetres, as the layout is. assert!( svg.starts_with("() ); let layout = &s.project.layouts[0]; for e in layout.elements.iter().filter(|e| e.visible) { assert!(svg.contains(&format!(r#""), "the title is text"); assert!(svg.contains(&format!(r#" 50, "the map frame is vectors, cut to the frame"); assert!(svg.contains("")); // SVG exports only the current series page, even when allPages is supplied. s.run("layout.series", &json!({"layer": "Lakes", "nameField": "name"})).unwrap(); s.run("layout.seriesPage", &json!({"page": "next"})).unwrap(); s.run("layout.update", &json!({"element": title, "text": "Page {page} of {pages}: {pageName}"})).unwrap(); let before = s.project.layouts[0].clone(); s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 36, "allPages": true})).unwrap(); let svg = std::fs::read_to_string(&out).unwrap(); assert!(svg.contains("Page 2 of ")); assert_eq!(s.project.layouts[0], before, "export keeps the selected page and frame"); let _ = std::fs::remove_file(&out); // A required map-source failure is reported without leaving an incomplete file. let dir = std::env::temp_dir().join(format!("rustgis-svg-failure-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut source = rustgis_map::source::SourceLayer::default(); source.path = Some(dir.join("missing.shp").display().to_string()); s.map_mut().layers[0].source = Some(source); // Use a world view so the missing layer is visible on the exported page. s.run("layout.frameZoom", &json!({"to": "full"})).unwrap(); let error = s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 36})).unwrap_err(); assert!(error.contains("map sources"), "{error}"); assert!(!out.exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn manual_breaks_are_the_ones_given() { use rustgis_map::{ClassMethod, Renderer}; let mut s = world(); let breaks = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { Renderer::Graduated { breaks, method, .. } => (breaks.clone(), *method), r => panic!("{r:?}"), }; let l = s.map().layer_by_name("Countries").unwrap(); let fi = l.dataset.field_index("pop_est").unwrap(); let max = (0..l.dataset.features.len()).filter_map(|r| l.dataset.value(r, fi).as_f64()).fold(f64::MIN, f64::max); let manual = |b: Value| json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "manual", "breaks": b}); // The bounds as typed; the field's maximum closes the last class, so every country has one. s.run("layer.symbology", &manual(json!([1e6, 1e7, 1e8]))).unwrap(); assert_eq!(breaks(&s), (vec![1e6, 1e7, 1e8, max], ClassMethod::Manual)); // Out of order or repeated, they come back sorted, once. s.run("layer.symbology", &manual(json!([5e7, 1e6, 5e7]))).unwrap(); assert_eq!(breaks(&s).0, vec![1e6, 5e7, max]); // "breaks" alone means manual. let mut p = manual(json!([2e7])); p.as_object_mut().unwrap().remove("method"); s.run("layer.symbology", &p).unwrap(); assert_eq!(breaks(&s), (vec![2e7, max], ClassMethod::Manual)); // Manual with no bounds keeps the layer's current ones, so switching to it changes nothing. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "quantile", "classes": 4})) .unwrap(); let q = breaks(&s).0; s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "manual"})).unwrap(); assert_eq!(breaks(&s), (q, ClassMethod::Manual)); // Bounds with a method that works them out are refused rather than ignored. let mut p = manual(json!([2e7])); p["method"] = json!("quantile"); assert!(s.run("layer.symbology", &p).is_err()); // Not numbers, none, or more than twelve classes are refused, and the layer keeps its style. for bad in [json!([]), json!(["a lot"]), json!((1..=13).map(|i| i as f64 * 1e6).collect::>())] { let before = breaks(&s); assert!(s.run("layer.symbology", &manual(bad.clone())).is_err(), "{bad}"); assert_eq!(breaks(&s), before); } } /// Raster-side projection failures retain good multipart coverage and reach both command results. #[test] fn raster_projection_failures_are_reported_for_zonal_statistics_and_clipping() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::Coord; let mut s = world(); let gt = GeoTransform { origin_x: 500_000.0, origin_y: 4_500_000.0, pixel_width: 1_000.0, pixel_height: -1_000.0 }; let raster = Raster::new("utm", 10, 10, vec![vec![7.0; 100]], gt, Crs::Epsg(32617), None, ColorHint::Gray, "Float32").unwrap(); let ring: Vec<_> = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0)] .into_iter() .map(|(x, y)| raster.transformer().to_lonlat(gt.to_world(x, y)).unwrap()) .collect(); let invalid = vec![Coord::new(-81.0, 100.0), Coord::new(-80.0, 100.0), Coord::new(-80.0, 101.0)]; assert!(raster.transformer().from_lonlat(invalid[0]).is_none()); s.map_mut().add_raster("utm", rustgis_map::RasterLayer::from_path(raster, "utm.tif")); let mut zones = Dataset::new("parts", GeometryType::Polygon, vec![]); zones.push(Some(Geometry::Polygon(vec![vec![ring], vec![invalid]])), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, zones)); s.run("gp.zonalStats", &json!({"raster": "utm", "polygons": "parts", "output": "stats"})).unwrap(); let messages = &s.project.gp_history.last().unwrap().messages; assert!(messages.iter().any(|message| message.contains("1 polygon part(s) could not be projected")), "{messages:?}"); let out = &s.map().layer_by_name("stats").unwrap().dataset; assert_eq!(out.value(0, out.field_index("utm_count").unwrap()), &rustgis_data::Value::Int(64)); assert_eq!(out.value(0, out.field_index("utm_mean").unwrap()), &rustgis_data::Value::Float(7.0)); let clip = s.run("raster.process", &json!({"layer": "utm", "operation": "clip", "polygon": "parts", "name": "clipped"})).unwrap(); assert!(clip["messages"].to_string().contains("skipped 1 polygon part(s)"), "{clip}"); let data = s.map().layer_by_name("clipped").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap(); assert_eq!((data.width, data.height), (8, 8)); } #[test] fn simplify_large_tolerances_keep_a_small_polygon_and_one_step_undo() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::Coord; let mut s = world(); let ring = (0..=40) .map(|i| { let angle = (i % 40) as f64 * std::f64::consts::TAU / 40.0; Coord::new(10.5 + 0.05 * angle.cos(), 46.5 + 0.05 * angle.sin()) }) .collect(); let mut zones = Dataset::new("zones", GeometryType::Polygon, vec![Field::new("code", FieldType::Text)]); zones.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Value::Text("Z1".into())]); s.map_mut().add_layer(rustgis_map::Layer::new(0, zones.clone())); let mut previous = zones.features[0].geometry.as_ref().unwrap().coord_count(); for tolerance in [0.1, 0.5, 1.0, 2.0, 5.0, 25.0, 100.0] { let depth = s.undo_depth(); s.run("gp.run", &json!({"tool":"simplify", "params":{"input":"zones", "tolerance":tolerance, "output":"simple"}})).unwrap(); let out = &s.map().layer_by_name("simple").unwrap().dataset; assert_eq!(out.features.len(), 1); assert_eq!(out.features[0].attrs, zones.features[0].attrs); let count = out.features[0].geometry.as_ref().unwrap().coord_count(); assert!((4..=previous).contains(&count), "{tolerance} km: {count} > {previous}"); previous = count; assert_eq!(s.map().layer_by_name("zones").unwrap().dataset, zones); assert_eq!(s.undo_depth(), depth + 1); assert!(s.undo()); assert!(s.map().layer_by_name("simple").is_none()); assert!(s.redo()); assert_eq!(s.map().layer_by_name("simple").unwrap().dataset.features[0].geometry.as_ref().unwrap().coord_count(), count); assert!(s.undo()); } assert_eq!(previous, 4); } #[test] fn publication_locator_keeps_small_islands_across_the_antimeridian() { use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::Coord; for (lon, lat) in [(0.0_f64, -5.0), (180.0, -5.0), (-180.0, 70.0)] { let mut s = world(); let ring: Vec<_> = [(-0.0005, 0.0), (0.0005, 0.0), (0.0005, 0.001), (-0.0005, 0.001), (-0.0005, 0.0)] .into_iter() .map(|(x, y)| Coord::new((lon + x + 180.0).rem_euclid(360.0) - 180.0, lat + y)) .collect(); let original = Geometry::Polygon(vec![vec![ring]]); let mut island = Dataset::new("island", GeometryType::Polygon, vec![]); island.push(Some(original.clone()), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, island)); let r = s.run("layout.publication", &json!({"extent": [-46.8, -11.6, -38.5, -2.2]})).unwrap(); let locator = s.project.maps.iter().find(|m| Some(m.name.as_str()) == r["locator"].as_str()).unwrap(); let geometry = &locator.layer_by_name("island").unwrap().dataset.features[0].geometry; assert_eq!(geometry.as_ref(), Some(&original), "island at {lon}, {lat}"); } } #[test] fn calculate_geometry_updates_ellipsoidal_lengths_in_one_undo_step() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::Coord; let mut s = world(); let mut routes = Dataset::new("routes", GeometryType::Polyline, vec![Field::new("code", FieldType::Text)]); routes.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0)]])), vec![Value::Text("R1".into())]); s.map_mut().add_layer(rustgis_map::Layer::new(0, routes.clone())); let depth = s.undo_depth(); s.run("gp.run", &json!({"tool":"calculateGeometry", "params":{"input":"routes"}})).unwrap(); let out = &s.map().layer_by_name("routes").unwrap().dataset; let length = out.field_index("length_km").unwrap(); assert!((out.value(0, length).as_f64().unwrap() - 111.31949079327356).abs() < 1e-6); assert_eq!(out.features[0].geometry, routes.features[0].geometry); assert_eq!(out.value(0, 0), &Value::Text("R1".into())); assert_eq!(s.undo_depth(), depth + 1); assert!(s.undo()); assert_eq!(s.map().layer_by_name("routes").unwrap().dataset, routes); assert!(s.redo()); let out = &s.map().layer_by_name("routes").unwrap().dataset; assert!((out.value(0, length).as_f64().unwrap() - 111.31949079327356).abs() < 1e-6); } /// The session log (#797): a command run by itself that changes the project, or reaches a file /// or a service, is logged once (a script's lines inside it), with its outcome; reading isn't. /// Undo keeps the entries and is logged with the step it took back (not when there is none). /// Secrets are taken out and large values clipped; the log is capped and kept for the session /// only, across the projects opened in it; it can be read by filter, written out, cleared and /// turned off, none of which changes the project. #[test] fn the_session_log_records_each_command_run_in_the_session() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::the_session_log_records_each_command_run_in_the_session") { return; } use crate::project_log::{LOG_LIMIT, LOG_PARAMS_BYTES, LOG_PLUGIN_LIMIT, LogEntry}; let mut s = world(); // The sample project's making is its first entry: counted from after it. assert_eq!(s.log.clear(), 1); let commands = |s: &Session| s.log.entries().map(|e| e.command.clone()).collect::>(); let last = |s: &Session| s.log.entries().last().cloned().unwrap(); s.run("project.rename", &json!({"name": "Atlas"})).unwrap(); s.run("info.project", &Value::Null).unwrap(); s.run("map.zoomIn", &Value::Null).unwrap(); s.run("script.run", &json!({"script": "project.rename {\"name\": \"A\"}\nproject.rename {\"name\": \"B\"}"})).unwrap(); assert_eq!(commands(&s), ["project.rename", "script.run"], "reading and moving the view aren't logged, nor a script's lines"); let first = s.log.entries().next().cloned().unwrap(); assert!(first.ok && first.error.is_none() && first.at > 1_700_000_000_000, "{first:?}"); assert_eq!(first.params, json!({"name": "Atlas"})); // Undone: the entries stay, and the undo says what it took back. s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.project.name, "Atlas"); assert_eq!(commands(&s), ["project.rename", "script.run", "edit.undo"]); assert_eq!(s.log.entries().nth(2).unwrap().params, json!({"step": "Run Script"})); // A failure is logged with its error; a service's address without its token. assert!(s.run("project.rename", &json!({"name": " "})).is_err()); let failed = last(&s); assert!(!failed.ok && failed.error.as_deref().is_some_and(|e| e.contains("name")), "{failed:?}"); let _ = s.run("service.layers", &json!({"url": "https://x.org/wms?token=S3CRET&layers=a", "text": "not a capabilities document"})); let service = last(&s); assert_eq!(service.command, "service.layers"); assert!(!service.params.to_string().contains("S3CRET"), "{service:?}"); // A document given as text is kept as its size only: it may hold secrets no redaction finds. let qgs = format!("{}", "rustgis-s3:TOKEN:s3://b/k ".repeat(20)); let _ = s.run("project.importQgis", &json!({"text": qgs})); let doc = last(&s); assert_eq!(doc.params["text"], json!(format!("(a document of {} characters)", qgs.chars().count())), "{doc:?}"); // A step joined to the newest entry (a held key's nudges) is a change too, so a view of the // log reads it again. let nudge = |millis| LogEntry { at: 1, command: "layout.nudge".into(), params: json!({"dx": 1}), ok: true, error: None, millis, ..Default::default() }; s.log.push(nudge(2)); let before = s.log.changes(); s.log.join_last(nudge(3)); assert!(s.log.changes() != before && last(&s).millis == 5, "{:?}", last(&s)); // So is one after a byte-order mark (a QGIS project read from a file that has one). let marked = format!("\u{feff}{qgs}"); let _ = s.run("project.importQgis", &json!({"text": marked})); let doc = last(&s); assert!(doc.params["text"].as_str().is_some_and(|t| t.starts_with("(a document of")), "{doc:?}"); // A secret in the JSON a text holds (a script's command line) is taken out too. let line = r#"s3.connect {"accessKeyId": "AKIA", "secretAccessKey": "S3CRETKEY", "region": "x"}"#; let _ = s.run("script.run", &json!({"script": line})); let script = last(&s); assert!(!script.params.to_string().contains("S3CRETKEY") && script.params.to_string().contains("s3.connect"), "{script:?}"); // Nor where the clip cuts the object short: what follows its `{` goes. // The secret comes first and a long field after it, past the cut. let long = format!(r#"s3.connect {{"secretAccessKey": "S3CRETKEY", "notes": "{}"}}"#, "x".repeat(5000)); let _ = s.run("script.run", &json!({"script": long})); let cut = last(&s); assert!(!cut.params.to_string().contains("S3CRET"), "{}", cut.params); // Nor in an object that doesn't parse (a trailing comma), while text such as `{name}` stays. let broken = r#"s3.connect {"secretAccessKey": "S3CRETKEY", "region": "x",} # see {name}"#; let _ = s.run("script.run", &json!({"script": broken})); let kept = last(&s).params.to_string(); assert!(!kept.contains("S3CRET") && kept.contains("s3.connect"), "{kept}"); let _ = s.run("script.run", &json!({"script": "# keep {name} as it is"})); assert!(last(&s).params.to_string().contains("{name}")); // A long list is "…" once its texts are cut to fit, its count with it. let long: Vec = (0..5_000).map(|i| format!("{i}{}", "y".repeat(100))).collect(); let _ = s.run("project.rename", &json!({"name": "Long", "tags": long})); let tags = last(&s).params.to_string(); assert_eq!(last(&s).params, json!({"name": "Long", "tags": "…"}), "{tags}"); // Large values clipped. s.run("project.rename", &json!({"name": "x".repeat(20_000), "note": vec![1; 5_000]})).ok(); let big = last(&s); assert!(serde_json::to_vec(&big.params).unwrap().len() <= LOG_PARAMS_BYTES, "{}", big.params.to_string().len()); // The project carries none of it: a saved project, or its recovery copy, holds no log. assert!(!s.project.saved_text(None).unwrap().0.contains("\"log\"")); assert!(!s.project.clone().recovery_text().unwrap().contains("\"log\"")); // Read by filter, newest last. let r = s.run("project.log", &json!({"command": "project.rename", "failed": true})).unwrap(); assert_eq!((r["count"].as_u64(), r["entries"][0]["command"].as_str()), (Some(1), Some("project.rename"))); assert_eq!(s.run("project.log", &json!({"limit": 2})).unwrap()["entries"].as_array().map(Vec::len), Some(2)); assert!(s.run("project.log", &json!({"limit": 0})).is_err()); assert!(s.run("project.log", &json!({"limit": 0.5})).unwrap_err().contains("whole number")); // Steps merged into one undo step (a held arrow key) are one entry, with the last parameters. let n = s.log.len(); for name in ["Nudged 1", "Nudged 2", "Nudged 3"] { s.merge_undo("nudge"); s.run("project.rename", &json!({"name": name})).unwrap(); } assert_eq!(s.log.len(), n + 1); assert_eq!(last(&s).params["name"], json!("Nudged 3")); // Times as decimals too; a negative time, or a filter that isn't true or false, is refused. assert_eq!(s.run("project.log", &json!({"since": 1.7e12, "limit": 1})).unwrap()["entries"].as_array().map(Vec::len), Some(1)); assert!(s.run("project.log", &json!({"since": -1})).is_err()); assert!(s.run("project.log", &json!({"failed": "true"})).is_err()); assert!(s.run("project.log", &json!({"command": 7})).is_err()); // An error that starts as a spreadsheet formula is written as text in the CSV. let formula = LogEntry { at: 2, command: "x".into(), params: Value::Null, ok: false, error: Some("=HYPERLINK(1)".into()), millis: 0, ..Default::default() }; s.log.push(formula); // A session plugin's request between its commands keeps its attribution in both. s.log.push(LogEntry { at: 3, command: "host.addLayer".into(), params: Value::Null, ok: true, error: None, millis: 0, plugin: Some("globe".into()), ..Default::default() }); // Written out as CSV and JSON Lines. let dir = std::env::temp_dir().join(format!("rustgis-session-log-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let csv = dir.join("log.csv"); s.run("project.exportLog", &json!({"path": csv.to_string_lossy()})).unwrap(); let csv_text = std::fs::read_to_string(&csv).unwrap(); assert!( csv_text.starts_with("time_utc,at,command,ok,millis,error,params,plugin,project\n") && csv_text.contains("\"edit.undo\""), "{csv_text}" ); assert!(csv_text.contains("\"'=HYPERLINK(1)\"") && !csv_text.contains(",\"=HYPERLINK"), "{csv_text}"); // The plugin is the cell before the project's name, the last. let plugin_cell = |l: &str| l.rsplit(',').nth(1).map(str::to_string); assert!(csv_text.lines().any(|l| l.contains("\"host.addLayer\"") && plugin_cell(l).as_deref() == Some("\"globe\"")), "{csv_text}"); assert!( csv_text.lines().any(|l| l.contains("\"edit.undo\"") && plugin_cell(l).as_deref() == Some("\"\"")), "the user's own carry none: {csv_text}" ); let jsonl = dir.join("log.jsonl"); s.run("project.exportLog", &json!({"path": jsonl.to_string_lossy()})).unwrap(); let lines: Vec = std::fs::read_to_string(&jsonl).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len() + 1, s.log.len(), "every entry but the export's own"); assert!(lines.iter().any(|e| e.plugin.as_deref() == Some("globe"))); let _ = std::fs::remove_dir_all(&dir); // Cleared, it says so; off, nothing goes in; on again, it does. Neither changes the project, // which holds no log, nor takes an undo step. for id in ["project.clearLog", "project.logCommands"] { assert!(!s.registry.get(id).unwrap().mutates, "{id}"); } let steps = s.undo.len(); s.mark_saved(); assert!(s.run("project.clearLog", &Value::Null).unwrap()["cleared"].as_u64().unwrap() > 5); assert_eq!(commands(&s), ["project.clearLog"]); assert!(!s.dirty() && s.undo.len() == steps, "the project doesn't change"); s.run("project.logCommands", &json!({"on": false})).unwrap(); s.run("project.rename", &json!({"name": "Quiet"})).unwrap(); assert_eq!(commands(&s), ["project.clearLog", "project.logCommands"], "turned off, and said so"); s.run("project.logCommands", &json!({"on": true})).unwrap(); assert_eq!(commands(&s), ["project.clearLog", "project.logCommands", "project.logCommands"]); // A .json path is refused: JSON Lines isn't one JSON document. A format given doesn't let // the log go over another kind of file, nor disagree with the path's extension. assert!(s.run("project.exportLog", &json!({"path": "log.json"})).is_err()); for (path, format) in [("log.json", "jsonl"), ("map.rgis", "csv"), ("log.csv", "jsonl"), ("log", "xml")] { let e = s.run("project.exportLog", &json!({"path": path, "format": format})).unwrap_err(); assert!(e.contains("not a .") || e.contains("doesn't match") || e.contains("format is"), "{path}: {e}"); } assert!(s.run("project.exportLog", &json!({"path": "log"})).unwrap_err().contains("give a .csv")); // The log lasts for the session: making a project adds to it and keeps what was there. let mut made = world(); let before = made.log.len(); made.run("project.new", &json!({"name": "Fresh"})).unwrap(); assert_eq!(made.log.len(), before + 1); assert_eq!(made.log.entries().last().unwrap().command, "project.new"); // Cleared while off, it still says so. s.run("project.logCommands", &json!({"on": false})).unwrap(); s.run("project.clearLog", &Value::Null).unwrap(); assert_eq!(commands(&s), ["project.clearLog"]); s.run("project.logCommands", &json!({"on": true})).unwrap(); // Capped: the oldest go first. for i in 0..LOG_LIMIT + 5 { s.log.push(LogEntry { at: i as u64, command: "x".into(), params: Value::Null, ok: true, error: None, millis: 0, ..Default::default() }); } assert_eq!(s.log.len(), LOG_LIMIT); assert_eq!(s.log.entries().next().unwrap().at, 5); // A session plugin's requests between its commands count with the plugins': past their limit // they make room among themselves, not by pushing out the user's entries. let user = s.log.entries().next().unwrap().at; for i in 0..LOG_PLUGIN_LIMIT + 5 { let e = LogEntry { at: 1_000_000 + i as u64, command: "host.addLayer".into(), params: Value::Null, ok: true, error: None, millis: 0, plugin: Some("globe".into()), ..Default::default() }; s.log.push(e); } assert_eq!(s.log.entries().filter(|e| e.plugin.is_some()).count(), LOG_PLUGIN_LIMIT); assert_eq!(s.log.entries().next().unwrap().at, user + LOG_PLUGIN_LIMIT as u64); // What was dropped is counted, and an export says so; a clear starts the count again. assert!(s.log.dropped() >= 5, "{}", s.log.dropped()); let out = std::env::temp_dir().join(format!("rustgis-log-dropped-{}.csv", std::process::id())); let mark = s.notice_mark(); s.run("project.exportLog", &json!({"path": out.display().to_string()})).unwrap(); assert!( s.notices_since(mark) .iter() .any(|n| n.text.contains("; not in it: ") && n.text.contains("oldest entries were dropped past the log's limit of 10000")), "{:?}", s.notices_since(mark) ); let _ = std::fs::remove_file(&out); s.log.clear(); assert_eq!(s.log.dropped(), 0); // A clean export says nothing of it. let mark = s.notice_mark(); s.run("project.exportLog", &json!({"path": out.display().to_string()})).unwrap(); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("were dropped past")), "{:?}", s.notices_since(mark)); let _ = std::fs::remove_file(&out); // Undo or redo with nothing to take back changes nothing, and isn't logged. s.clear_history(); let n = s.log.len(); assert!(s.run("edit.undo", &Value::Null).is_err() && s.run("edit.redo", &Value::Null).is_err()); assert_eq!(s.log.len(), n, "nothing to undo or redo: not logged"); } /// Each entry names the project it ran in (the one it opened, for a command that opens one), and /// another project of the same name is told apart; parameters cut to fit or without their secrets /// are said to be partial, as they wouldn't run as they ran; a saved log keeps the project's name. #[test] fn session_log_entries_name_their_project_and_say_when_their_params_are_partial() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::session_log_entries_name_their_project_and_say_when_their_params_are_partial") { return; } let mut s = world(); s.log.clear(); let last = |s: &Session| s.log.entries().last().cloned().unwrap(); s.run("project.rename", &json!({"name": "Atlas"})).unwrap(); let renamed = last(&s); assert_eq!((renamed.project.as_str(), renamed.opened, renamed.partial), ("Atlas", s.opened, false), "{renamed:?}"); s.run("project.new", &json!({"name": "Atlas"})).unwrap(); let made = last(&s); assert_eq!((made.command.as_str(), made.project.as_str(), made.opened), ("project.new", "Atlas", s.opened)); assert_ne!(made.opened, renamed.opened, "the same name, another project"); // A script with nothing to take out is kept as written, its spacing too: not partial. let script = "map.new {\"name\": \"Third\"}\n# a { that isn't JSON"; s.run("script.run", &json!({"script": script})).unwrap(); let ran = last(&s); assert_eq!((ran.params["script"].as_str(), ran.partial), (Some(script), false), "{ran:?}"); // A secret's key written twice is read as its last value: the first isn't kept as written. let _ = s.run("script.run", &json!({"script": "map.new {\"name\": \"M\", \"secretAccessKey\": \"zzzz\", \"secretAccessKey\": \"\"}"})); let twice = last(&s); assert!(!twice.params.to_string().contains("zzzz") && twice.partial, "{twice:?}"); // Nor with its name escaped, nor a key a plain name blanks only for its value, nor deeper in; // and a key only logs leave out (`passphrase`) isn't kept either, nor one past eight levels. for script in [ r#"map.new {"name": "M", "passphrase": "zzzz"}"#, r#"map.new {"name": "M", "a": [[[[[[[[[{"passphrase": "zzzz"}]]]]]]]]]}"#, r#"map.new {"name": "M", "\u0074oken": "zzzz", "\u0074oken": ""}"#, r#"map.new {"name": "M", "key": "AKIAIOSFODNN7zzzz", "key": ""}"#, r#"map.new {"name": "M", "style": {"token": "zzzz", "token": ""}}"#, ] { let before = s.log.len(); let _ = s.run("script.run", &json!({"script": script})); let e = last(&s); assert_eq!((s.log.len(), e.command.as_str()), (before + 1, "script.run"), "{script}: its own entry"); assert!(!e.params.to_string().contains("zzzz") && e.partial, "{script}: {e:?}"); } // A name past the bound is cut, and says so. s.project.name = "n".repeat(crate::cmd::MAX_NAME + 5); s.run("map.new", &json!({"name": "Long"})).unwrap(); assert_eq!(last(&s).project, format!("{}…", "n".repeat(crate::cmd::MAX_NAME))); s.project.name = "Atlas".into(); // A script past the log's bound is cut: partial, so it isn't run again as a part. let long = format!("# {}", "x".repeat(crate::project_log::LOG_PARAMS_BYTES * 2)); s.run("script.run", &json!({"script": long})).unwrap(); assert!(last(&s).partial); // An address without its token: partial too. let _ = s.run("service.layers", &json!({"url": "https://x.org/wms?token=zzzz&layers=a", "text": "not a capabilities document"})); let service = last(&s); assert_eq!(service.command, "service.layers", "{service:?}"); assert!(!service.params.to_string().contains("zzzz") && service.partial, "{service:?}"); // And `project.log` says so: `partial` is in its answer. let answer = s.run("project.log", &json!({"command": "service.layers", "limit": 1})).unwrap(); assert_eq!(answer["entries"][0]["partial"], json!(true), "{answer}"); // Saved: the project's name last in a CSV row; in JSON, the name, and `partial` only when so. let mut csv = Vec::new(); s.log.write(&mut csv, true).unwrap(); let csv = String::from_utf8(csv).unwrap(); assert!(csv.lines().nth(1).is_some_and(|row| row.ends_with(",\"Atlas\"")), "{csv}"); let json = serde_json::to_value(&renamed).unwrap(); assert_eq!(json["project"], "Atlas"); assert!(json.get("opened").is_none() && json.get("partial").is_none(), "{json}"); } /// What the session log keeps of a command's parameters: bounded in bytes, keys and time, and /// without secrets, those in JSON a text holds and in SQL `CREATE … SECRET` options included. #[test] fn the_session_log_keeps_parameters_bounded_and_without_secrets() { // Many keys with short values still fit the bound. let wide: serde_json::Map = (0..40).map(|i| (format!("{i:02}{}", "k".repeat(62)), json!("é".repeat(64)))).collect(); let kept = crate::project_log::kept_params(&Value::Object(wide)); assert!(serde_json::to_vec(&kept).unwrap().len() <= crate::project_log::LOG_PARAMS_BYTES, "{kept}"); assert!(kept.as_object().is_some_and(|o| o.len() > 1 && o.len() < 40)); assert_eq!(kept["…"], json!("40 keys in all")); // Keys alike in their first 64 characters: the first stays, the other counted as left out. let mut alike: serde_json::Map = (0..40).map(|i| (format!("{i:02}{}", "k".repeat(62)), json!("v".repeat(64)))).collect(); // Sorts right after the first key, and is the same once cut to 64 characters. alike.insert(format!("00{}a", "k".repeat(62)), json!(2)); let kept = crate::project_log::kept_params(&Value::Object(alike)); assert_eq!(kept["…"], json!("41 keys in all")); assert_eq!(kept[format!("00{}", "k".repeat(62))], json!("v".repeat(64)), "the first stays"); assert!(kept.as_object().is_some_and(|o| !o.values().any(|v| *v == json!(2))), "{kept}"); // A crafted text costs its length, not its braces times its length: 200,000 braces are // milliseconds once, and would be hours if each `{` scanned the rest (a bound far from both). let crafted = "{1".repeat(200_000); let started = std::time::Instant::now(); assert_eq!(crate::project_log::json_in_text_redacted(&crafted).len(), crafted.len()); assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); // A key cut short keeps no value: a secret's name may be past the cut. let long_key = format!("{}secretAccessKey", "x".repeat(195)); let kept = crate::project_log::kept_params(&json!({long_key: "S3CRET"})); assert!(!kept.to_string().contains("S3CRET"), "{kept}"); // An SQL secret's options, written as plain words, go; the statement's shape stays. let sql = "CREATE OR REPLACE SECRET s3 (TYPE s3, KEY_ID 'AKIA1', SECRET 'S3CRET'); SELECT 1 / 2"; let kept = crate::project_log::kept_params(&json!({"sql": sql})); assert_eq!(kept["sql"], json!("CREATE OR REPLACE SECRET s3 (…); SELECT 1 / 2")); assert_eq!(crate::project_log::sql_secrets_removed("create table t (a int)"), "create table t (a int)"); assert_eq!(crate::project_log::sql_secrets_removed("create table secret (a int)"), "create table secret (a int)"); // A file's contents in base64 (`layer.addDataBytes`) are kept as their size: a secret's name // can't be seen in them. A long text that isn't base64 is clipped as before. let encoded = "eyJ0b2tlbiI6ICJTM0NSRVQifQ==".repeat(20); for data in [encoded.clone(), format!("data:application/json;base64,{encoded}")] { let kept = crate::project_log::kept_params(&json!({"fileName": "a.geojson", "data": data})); assert_eq!(kept["data"], json!(format!("(file contents of {} characters, base64)", data.len())), "{kept}"); } let prose = crate::project_log::kept_params(&json!({"note": "word ".repeat(100)})); assert!(prose["note"].as_str().is_some_and(|n| n.starts_with("word word")), "{prose}"); // `create` inside another word starts no statement. for t in ["recreate secret s (key 'a')", "created secret (key 'a')"] { assert_eq!(crate::project_log::sql_secrets_removed(t), t); } assert_eq!(crate::project_log::sql_secrets_removed("CREATE PERSISTENT SECRET (TYPE s3, SECRET 'x')"), "CREATE PERSISTENT SECRET (…)"); // An unquoted secret key in an object that doesn't parse goes, with the rest of the text; // a format string's braces stay. let line = crate::project_log::kept_params(&json!({"script": "s3.connect {secretAccessKey: \"S3CRET\"}\nprint(f\"{x:.2f}\")"})); assert!(!line.to_string().contains("S3CRET"), "{line}"); let fmt = crate::project_log::kept_params(&json!({"script": "print(f\"{x:.2f}\")"})); assert_eq!(fmt["script"], json!("print(f\"{x:.2f}\")")); // A quoted secret key in such an object goes too, its value with it, double or single quotes. for line in [r#"s3.connect {a: "x", "secretAccessKey": "S3CRET",}"#, "s3.connect {a: 'x', 'secretAccessKey': 'S3CRET',}"] { let kept = crate::project_log::kept_params(&json!({"script": line})); assert!(!kept.to_string().contains("S3CRET") && kept.to_string().contains("s3.connect"), "{kept}"); } // A response is logged as its own size, also when the rest makes the entry large. let answer = "a".repeat(41); let notes: Vec = (0..50).map(|_| json!("n".repeat(200))).collect(); let kept = crate::project_log::kept_params(&json!({"answer": answer, "notes": notes})); assert_eq!(kept["answer"], json!("(a response of 41 characters)"), "{kept}"); let kept = crate::project_log::kept_params(&json!({"answer": "short"})); assert_eq!(kept["answer"], json!("(a response of 5 characters)"), "{kept}"); } /// Plugins can neither empty the session log nor turn it off, nor push the user's entries out of /// it; writing the log out, or reading it, reaches a file. #[test] fn plugins_can_neither_clear_the_session_log_nor_crowd_out_the_users_entries() { use crate::project_log::LogEntry; // Plugins' commands keep at most their own share of the log: the user's entries stay. let mut log = crate::project_log::SessionLog::default(); let entry = |command: &str| LogEntry { at: 1, command: command.into(), params: Value::Null, ok: true, error: None, millis: 0, ..Default::default() }; log.push(entry("layer.rename")); for _ in 0..crate::project_log::LOG_PLUGIN_LIMIT + 5 { log.push(entry("plugin:p/flood")); } assert_eq!(log.len(), crate::project_log::LOG_PLUGIN_LIMIT + 1); assert_eq!(log.entries().next().unwrap().command, "layer.rename"); // A plugin never empties the log or turns it off; writing it out is reaching a file. assert!(crate::cmd::plugins::user_only("project.clearLog") && crate::cmd::plugins::user_only("project.logCommands")); { use crate::cmd::plugins::{Reach, reaches}; let export = crate::cmd::all().into_iter().find(|c| c.id == "project.exportLog").unwrap(); assert_eq!(reaches(&export, &json!({"path": "log.csv"})), Some(Reach::Files)); // Reading it too: it names the files and addresses the user's commands used. let read = crate::cmd::all().into_iter().find(|c| c.id == "project.log").unwrap(); assert_eq!(reaches(&read, &json!({})), Some(Reach::Files)); } } /// `raster.project` puts a raster on a grid of another coordinate system, cell size, origin and /// extent, or on another raster's grid: grids of the same cell and origin line up, a polygon /// layer's selection gives the extent, and bad parameters are refused before anything is made. #[test] fn a_raster_projected_onto_a_chosen_grid() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let mut s = world(); // 40 × 40 pixels of 0.001° near Teresina, value = column + 100 · row. let gt = GeoTransform { origin_x: -42.82, origin_y: -5.08, pixel_width: 0.001, pixel_height: -0.001 }; let v: Vec = (0..1600).map(|i| (i % 40 + 100 * (i / 40)) as f32).collect(); let r = Raster::new("the", 40, 40, vec![v], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = rustgis_data::raster::geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "the", rustgis_map::RasterLayer::from_bytes(r, "the.tif", bytes)).unwrap(); // SIRGAS 2000 / UTM 23S, 200 m cells averaged: about 22 × 22 cells, means within the values. let v = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 200, "resampling": "average"})).unwrap(); assert_eq!( (v["crs"].as_str(), v["resampling"].as_str(), v["name"].as_str()), (Some("EPSG:31983"), Some("average"), Some("the projected")) ); let out = out_raster(&s, &v); assert!((22..=24).contains(&out.width) && (22..=24).contains(&out.height), "{} × {}", out.width, out.height); let mid = out.value(0, i64::from(out.width) / 2, i64::from(out.height) / 2).unwrap(); assert!((0.0..=3939.0).contains(&mid) && mid.fract() != 0.0, "a mean of several pixels: {mid}"); // Cell corners on whole multiples of the cell (origin [0, 0]), here and over a part of it. let on_grid = |v: &Value| { let c = &v["corner"]; (c[0].as_f64().unwrap() / 200.0).fract() == 0.0 && (c[1].as_f64().unwrap() / 200.0).fract() == 0.0 }; assert!(on_grid(&v)); let part = s .run( "raster.project", &json!({"layer": "the", "crs": "EPSG:31983", "cellSize": 200, "extent": [-42.81, -5.11, -42.79, -5.09], "name": "part"}), ) .unwrap(); assert!(on_grid(&part) && part["width"].as_u64() < v["width"].as_u64(), "{part}"); // Another origin moves the corners with it. let moved = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 200, "origin": [50, 50], "name": "moved"})).unwrap(); // An origin with anything but two numbers is refused, not read from the numbers left. for bad in [json!(["x", 1, 2]), json!([0, 0, "EPSG"]), json!([1])] { assert!(s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 200, "origin": bad})).is_err(), "{bad}"); } // Projected again with the default name: the name reported is the one the layer got. let first = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 500})).unwrap(); let again = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 500})).unwrap(); assert_ne!(first["name"], again["name"]); for r in [&first, &again] { assert!(s.map().layer_by_name(r["name"].as_str().unwrap()).is_some(), "{r}"); } // An extent or a grid of the wrong kind is refused, not read as the view or left out. for bad in [json!({"extent": 5}), json!({"extent": {}}), json!({"extent": true}), json!({"grid": 5}), json!({"grid": ["the"]})] { let mut p = bad.clone(); p["layer"] = json!("the"); let e = s.run("raster.project", &p).unwrap_err(); assert!(e.contains("extent is") || e.contains("grid is"), "{bad}: {e}"); } assert_eq!((moved["corner"][0].as_f64().unwrap() - 50.0).rem_euclid(200.0), 0.0, "{moved}"); // Onto that grid exactly, by nearest neighbour (the default). let again = s.run("raster.project", &json!({"layer": "the", "grid": "the projected", "name": "again"})).unwrap(); let a = out_raster(&s, &again); assert_eq!((a.width, a.height, a.transform, a.crs), (out.width, out.height, out.transform, out.crs)); assert_eq!(again["resampling"], "nearest"); // The extent of a polygon layer's selection: Brazil, in 50 km cells. s.run("select.byAttributes", &json!({"layer": "Countries", "where": "name = 'Brazil'"})).unwrap(); let br = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 50_000, "extent": "Countries", "name": "br"})).unwrap(); let (w, h) = (br["width"].as_u64().unwrap(), br["height"].as_u64().unwrap()); assert!((70..=130).contains(&w) && (70..=130).contains(&h), "{w} × {h}"); // Refused: a grid with its own settings, bad cells, origins, extents and methods, a grid too // large to hold, and a file that isn't a GeoTIFF. let bad = [ json!({"layer": "the", "grid": "the projected", "crs": 31983}), json!({"layer": "the", "grid": "Countries"}), json!({"layer": "the", "cellSize": -1}), json!({"layer": "the", "cellSize": [1, 0]}), json!({"layer": "the", "cellSize": "x"}), json!({"layer": "the", "origin": "x"}), json!({"layer": "the", "origin": [1, f64::NAN]}), json!({"layer": "the", "extent": [170.0, -10.0, -170.0, 10.0]}), json!({"layer": "the", "extent": "Nowhere"}), json!({"layer": "the", "resampling": "lanczos"}), json!({"layer": "the", "crs": 31983, "cellSize": 0.001}), json!({"layer": "the", "crs": "nonsense"}), json!({"layer": "the", "path": "out.png"}), ]; let before = s.map().layers.len(); for p in bad { assert!(s.run("raster.project", &p).is_err(), "{p}"); } assert_eq!(s.map().layers.len(), before, "nothing added by a refused run"); let e = s.run("raster.project", &json!({"layer": "the", "crs": 31983, "cellSize": 0.001})).unwrap_err(); assert!(e.contains("give a larger cellSize"), "{e}"); } /// Importing a project must preserve the current map, styles, inherited opacity and undo. #[test] fn geolibre_import_inline_styles_groups_and_undo() { let mut s = world(); let maps = s.project.maps.len(); let original = s.map().name.clone(); let feature = json!({"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"kind":"a","value":10}}]}); let text=json!({"version":"0.2.0","name":"Imported","mapView":{"center":[10,20],"zoom":5,"bearing":15},"basemapStyleUrl":"","layerGroups":[{"id":"g","name":"Half","opacity":0.5,"visible":false}],"layers":[{"id":"a","name":"Points","type":"geojson","geojson":feature,"groupId":"g","opacity":0.5,"style":{"vectorStyleMode":"categorized","vectorStyleProperty":"kind","vectorStyleStops":[{"value":"a","color":"#ff0000"}],"labels":{"enabled":true,"field":"kind"}}}],"bookmarks":[{"name":"Place","lng":1,"lat":2,"zoom":3}]}).to_string(); let out = s.run("project.importGeolibre", &json!({"text":text})).unwrap(); assert_eq!(out["layers"], json!(["Points"])); assert_eq!(s.project.maps.len(), maps + 1); let layer = &s.map().layers[0]; assert!(layer.visible); assert_eq!(layer.transparency, 50.0); assert!(!s.map().groups[0].visible); assert_eq!(s.map().groups[0].transparency, 50.0); assert!(matches!(layer.renderer, rustgis_map::Renderer::Unique { .. })); assert!(layer.labels.enabled); assert_eq!(s.map().groups[0].children, vec![layer.id]); assert_eq!(s.map().bookmarks[0].name, "Place"); assert_eq!(s.map().camera.bearing, 15.0); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.project.maps.len(), maps); assert_eq!(s.map().name, original); assert!(crate::cmd::plugins::user_only("project.importGeolibre")); } #[test] fn geolibre_input_channels_missing_sources_and_malformed_data() { use base64::Engine; let text=json!({"version":"0.2.0","name":"Empty","mapView":{},"basemapStyleUrl":"","layers":[{"name":"Missing","type":"geojson","sourcePath":"/no/such/geolibre-data.geojson"},{"name":"Video","type":"video"}]}).to_string(); for p in [json!({"text":text}), json!({"data":base64::engine::general_purpose::STANDARD.encode(&text)})] { let mut s = world(); let out = s.run("project.importGeolibre", &p).unwrap(); assert_eq!(out["layers"], json!([])); assert_eq!(out["notes"].as_array().unwrap().len(), 2); } let mut s = world(); let maps = s.project.maps.len(); for p in [json!({"text":"{"}), json!({"text":"null"}), json!({"data":"!!!!"}), json!({"url":"file:///etc/passwd"})] { assert!(s.run("project.importGeolibre", &p).is_err()); assert_eq!(s.project.maps.len(), maps); } } #[test] fn geolibre_moved_local_source_uses_project_directory() { let dir = std::env::temp_dir().join(format!("rustgis-geolibre-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let data = dir.join("points.geojson"); std::fs::write( &data, r#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{}}]}"#, ) .unwrap(); let path = dir.join("map.geolibre"); std::fs::write(&path,json!({"version":"0.2.0","name":"Moved","mapView":{},"basemapStyleUrl":"","layers":[{"name":"Points","type":"geojson","sourcePath":"C:/old/place/points.geojson"}]}).to_string()).unwrap(); let mut s = world(); let out = s.run("project.importGeolibre", &json!({"path":path})).unwrap(); assert_eq!(out["layers"], json!(["Points"])); assert_eq!(s.map().layers[0].dataset.features.len(), 1); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geolibre_download_finishes_in_its_original_map_and_position() { let mut s = world(); let mut ds = rustgis_data::Dataset::new("Points", rustgis_data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:test".into()); let target = s.project.active_map; let id = s.map_mut().add_dataset(ds); s.map_mut().layer_mut(id).unwrap().name = "Renamed while downloading".into(); s.map_mut().layer_mut(id).unwrap().visible = false; s.map_mut().layer_mut(id).unwrap().transparency = 35.0; let camera = s.map().camera; s.run("map.new", &json!({"name":"Other"})).unwrap(); let active = s.project.active_map; let download = s.download_started(); s.download_landed(download,br#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"value":1}}]}"#.to_vec()); let out=s.run("layer.addDownload",&json!({"id":download,"url":"https://example.org/points.geojson","name":"Points","geolibreLayer":{"token":"geolibre-pending:test","record":{"style":{"circleRadius":9}}}})).unwrap(); assert_eq!(out["layer"], id); assert_eq!(s.project.active_map, active); let map = &s.project.maps[target]; let loaded = map.layer(id).unwrap(); assert_eq!(loaded.dataset.features.len(), 1); assert_eq!(loaded.name, "Renamed while downloading"); assert!(!loaded.visible); assert_eq!(loaded.transparency, 35.0); assert_eq!(loaded.dataset.name, loaded.name); assert_eq!(loaded.renderer.base_symbol().size, 18.0); assert_eq!(map.camera, camera); assert!(s.map().layers.is_empty()); // Completion and an earlier map switch must not resurrect the consumed reservation. for _ in 0..2 { s.run("edit.undo", &json!({})).unwrap(); assert!(s.project.saved_text(None).is_ok()); assert!(s.project.maps.iter().all(|m| m.layers.iter().all(|l| l.dataset.source.as_deref() != Some("geolibre-pending:test")))); } for _ in 0..2 { s.run("edit.redo", &json!({})).unwrap(); assert!(s.project.saved_text(None).is_ok()); } assert_eq!(s.project.maps[target].layer(id).unwrap().dataset.features.len(), 1); } #[test] fn geolibre_tile_sources_keep_visibility_opacity_and_order() { let mut s = world(); let layers:Vec<_>=["xyz","wms","wmts","vector-tiles"].into_iter().enumerate().map(|(i,kind)|json!({"id":kind,"name":kind,"type":kind,"visible":false,"opacity":0.3,"source":{"tiles":[format!("https://example.org/{i}/{{z}}/{{x}}/{{y}}.png")]}})).collect(); let out = s .run( "project.importGeolibre", &json!({"text":json!({"version":"0.2.0","name":"Tiles","mapView":{},"basemapStyleUrl":"","layers":layers}).to_string()}), ) .unwrap(); assert_eq!(out["layers"].as_array().unwrap().len(), 4); assert_eq!(s.map().tiles.len(), 4); assert!(s.map().tiles.iter().all(|t| !t.visible && t.opacity == 0.3)); assert_eq!(s.map().tiles[0].name, "vector-tiles"); assert_eq!(s.map().tiles[3].name, "xyz"); } /// A file is let go of with its last layer, however that went (Windows won't move or delete a file /// SQLite has open), and one still on a map keeps its connections for the next tile. #[cfg(not(target_arch = "wasm32"))] #[test] fn mbtiles_files_are_let_go_of_with_their_last_layer() { use rustgis_data::mbtiles::{close, is_open, tile_path}; struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { fn drop(&mut self) { // Also clean up if an assertion fails with the file still open. close(&self.0); let _ = std::fs::remove_file(&self.0); } } let path = std::env::temp_dir().join(format!("rustgis-mbtiles-release-{}.mbtiles", std::process::id())); let _ = std::fs::remove_file(&path); let _cleanup = Cleanup(path.clone()); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch("CREATE TABLE metadata(name TEXT,value TEXT); CREATE TABLE tiles(zoom_level INTEGER,tile_column INTEGER,tile_row INTEGER,tile_data BLOB); INSERT INTO metadata VALUES('format','png'),('minzoom','0'),('maxzoom','0'); INSERT INTO tiles VALUES(0,0,0,x'00');").unwrap(); drop(db); let mut s = Session::new(Project::default()); s.run("map.addTiles", &json!({"url": path.display().to_string(), "kind": "mbtiles"})).unwrap(); // The pool is the process's, and another test's command can close the file at any moment, so // this asks only that a command leave it open once: it fails if every command closes it. let stays_open = |s: &mut Session| { (0..100).any(|_| { tile_path(&path, 0, 0, 0).unwrap(); s.run("map.graticule", &json!({})).unwrap(); is_open(&path) }) }; assert!(stays_open(&mut s), "a layer on the map keeps its connections"); s.run("map.addTiles", &json!({"url": path.display().to_string(), "kind": "mbtiles", "overlay": true})).unwrap(); s.run("map.removeTiles", &json!({"index": 0})).unwrap(); assert!(stays_open(&mut s), "the file's other layer keeps them"); s.run("map.removeTiles", &json!({})).unwrap(); assert!(!is_open(&path), "the layer removed"); s.run("edit.undo", &json!({})).unwrap(); assert!(stays_open(&mut s), "the layer back"); s.run("project.new", &json!({})).unwrap(); assert!(!is_open(&path), "the project closed"); std::fs::remove_file(&path).unwrap(); } /// A file replaced after its last layer went is read again: its metadata, the style made from them /// and its tiles, not what was kept of the old one. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_replaced_mbtiles_file_is_read_again() { use crate::render::rustgis_tiles::{cache, mbtiles, source}; struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { fn drop(&mut self) { rustgis_data::mbtiles::close(&self.0); let _ = std::fs::remove_file(&self.0); } } let path = std::env::temp_dir().join(format!("rustgis-mbtiles-replaced-{}.mbtiles", std::process::id())); let _ = std::fs::remove_file(&path); let _cleanup = Cleanup(path.clone()); let write = |size: u32, max_zoom: u8| { if path.exists() { std::fs::remove_file(&path).unwrap(); } let mut png = Vec::new(); image::RgbaImage::new(size, size).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch("CREATE TABLE metadata(name TEXT,value TEXT); CREATE TABLE tiles(zoom_level INTEGER,tile_column INTEGER,tile_row INTEGER,tile_data BLOB);").unwrap(); db.execute("INSERT INTO metadata VALUES('format','png'),('minzoom','0'),('maxzoom',?1),('attribution',?1)", [max_zoom.to_string()]) .unwrap(); db.execute("INSERT INTO tiles VALUES(0,0,0,?1)", [png]).unwrap(); }; let url = path.display().to_string(); let tile = mbtiles::template(&url).replace("{z}", "0").replace("{x}", "0").replace("{y}", "0"); let tile_width = || { for _ in 0..1000 { match cache::get_tile(&tile, cache::Want::Image) { cache::State::Ready(payload) => match &*payload { cache::Payload::Image(image) => return image.width(), _ => panic!("not an image"), }, cache::State::Failed(e) => panic!("{e}"), cache::State::Pending => std::thread::sleep(std::time::Duration::from_millis(10)), } } panic!("the tile never came"); }; let credits = |s: &Session| { for _ in 0..1000 { match source::resolve(&crate::render::tile_spec(&s.map().tiles[0])) { source::Resolve::Ready(style) => return style.attribution.clone(), source::Resolve::Failed(e) => panic!("{e}"), _ => std::thread::sleep(std::time::Duration::from_millis(10)), } } panic!("the style never came"); }; let mut s = Session::new(Project::default()); write(2, 1); s.run("map.addTiles", &json!({"url": url, "kind": "mbtiles"})).unwrap(); assert_eq!((mbtiles::info(&url).unwrap().max_zoom, credits(&s), tile_width()), (1, vec!["1".to_string()], 2)); s.run("map.removeTiles", &json!({})).unwrap(); write(4, 5); s.run("map.addTiles", &json!({"url": url, "kind": "mbtiles"})).unwrap(); assert_eq!((mbtiles::info(&url).unwrap().max_zoom, credits(&s), tile_width()), (5, vec!["5".to_string()], 4)); } /// Sources authored by this test exercise the dispatch for every supported local data kind. #[cfg(not(target_arch = "wasm32"))] #[test] fn geolibre_local_source_kinds_and_archives() { let dir = std::env::temp_dir().join(format!("rustgis-geolibre-kinds-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (ds,_)=rustgis_data::io::read_bytes("points.geojson",br#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"value":1}}]}"#).unwrap(); for (file, ext) in [("points.fgb", "fgb"), ("points.parquet", "parquet")] { std::fs::write(dir.join(file), rustgis_data::io::write_bytes(&ds, ext).unwrap().0).unwrap(); } std::fs::write(dir.join("points.las"), las_bytes(&[(10.5, 50.5, 300.0, 2)])).unwrap(); let raster = rustgis_data::raster::ascii::read( "ncols 2\nnrows 2\nxllcorner 0\nyllcorner 0\ncellsize 1\nNODATA_value -9999\n1 2\n3 4\n", "grid", Some(rustgis_data::raster::Crs::LonLat), ) .unwrap(); let tif = rustgis_data::raster::geotiff::write(&raster, 0).unwrap(); std::fs::write(dir.join("grid.tif"), &tif).unwrap(); let mut tile = tiny_skia::Pixmap::new(1, 1).unwrap(); tile.fill(tiny_skia::Color::from_rgba8(10, 20, 30, 255)); let png = tile.encode_png().unwrap(); let archive = crate::render::rustgis_tiles::pmtiles::NewArchive { tile_type: crate::render::rustgis_tiles::pmtiles::TileType::Png, gzipped_tiles: false, bounds: [-180.0, -85.0, 180.0, 85.0], metadata: json!({}), }; std::fs::write( dir.join("tiles.pmtiles"), crate::render::rustgis_tiles::pmtiles::write(vec![(0, 0, 0, png.clone())], &archive).unwrap(), ) .unwrap(); let db = rusqlite::Connection::open(dir.join("tiles.mbtiles")).unwrap(); db.execute_batch("CREATE TABLE metadata(name TEXT,value TEXT); CREATE TABLE tiles(zoom_level INTEGER,tile_column INTEGER,tile_row INTEGER,tile_data BLOB); INSERT INTO metadata VALUES('format','png'),('minzoom','0'),('maxzoom','0');").unwrap(); db.execute("INSERT INTO tiles VALUES(0,0,0,?1)", [png]).unwrap(); drop(db); let store = dir.join("grid.zarr"); std::fs::create_dir_all(&store).unwrap(); let arr = |shape: Value, chunks: Value| json!({"zarr_format":2,"shape":shape,"chunks":chunks,"dtype":">()).unwrap(); } let sources = [ ("flatgeobuf", "points.fgb"), ("geoparquet", "points.parquet"), ("lidar", "points.las"), ("raster", "grid.tif"), ("cog", "grid.tif"), ("zarr", "grid.zarr"), ("pmtiles", "tiles.pmtiles"), ("mbtiles", "tiles.mbtiles"), ]; let layers: Vec<_> = sources .iter() .map(|(kind, file)| json!({"id":kind,"name":kind,"type":kind,"sourcePath":file,"source":{"variable":"value","index":{"time":1}}})) .collect(); let mut s = world(); let out=s.run("project.importGeolibre",&json!({"dir":dir,"text":json!({"version":"0.2.0","name":"Sources","mapView":{},"basemapStyleUrl":"","layers":layers}).to_string()})).unwrap(); assert_eq!(out["layers"].as_array().unwrap().len(), sources.len(), "{out}"); assert_eq!(s.map().layers.len(), 6); assert_eq!(s.map().tiles.len(), 2); let raster = s.map().layer_by_name("zarr").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap(); assert!(raster.bands[0].data.iter().all(|v| *v >= 101.0 && *v <= 104.0), "saved time slice must survive native import"); // The archives stay open while the session draws them (MBTiles metadata is read on a // background thread), and Windows refuses to delete an open file: release them, and treat // the cleanup as best effort like the other tests do. drop(s); rustgis_data::mbtiles::close(&dir.join("tiles.mbtiles")); std::fs::remove_dir_all(&dir).ok(); } #[test] fn geolibre_saved_order_overrides_automatic_geometry_ranking() { let mut s = world(); let features = [ ("point", json!({"type":"Point","coordinates":[0,0]})), ("polygon", json!({"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]})), ]; let layers:Vec<_>=features.into_iter().map(|(name,geometry)|json!({"id":name,"name":name,"type":"geojson","geojson":{"type":"FeatureCollection","features":[{"type":"Feature","geometry":geometry,"properties":{}}]}})).collect(); s.run( "project.importGeolibre", &json!({"text":json!({"version":"0.2.0","name":"Order","mapView":{},"basemapStyleUrl":"","layers":layers}).to_string()}), ) .unwrap(); assert_eq!(s.map().layers.iter().map(|l| l.name.as_str()).collect::>(), vec!["polygon", "point"]); } #[test] fn geolibre_failed_download_discards_only_its_own_reservation() { let mut s = world(); let original = s.map().layers.len(); let mut ds = rustgis_data::Dataset::new("Pending", rustgis_data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:failure".into()); let id = s.map_mut().add_dataset(ds); let mut group = rustgis_map::LayerGroup::new("Group"); group.children.push(id); s.map_mut().add_group(group); s.run("map.new", &json!({"name":"Other"})).unwrap(); crate::cmd::geolibre::discard_pending(&mut s, &json!({"geolibreLayer":{"token":"geolibre-pending:failure"}})); assert_eq!(s.project.maps[0].layers.len(), original); assert!(s.project.maps[0].groups.iter().all(|g| !g.children.contains(&id))); assert_eq!(s.map().name, "Other"); } #[test] fn geolibre_group_without_id_does_not_claim_ungrouped_layers() { let mut s = world(); let text = json!({"version":"0.2.0","name":"Groups","mapView":{"center":[0,0],"zoom":3},"basemapStyleUrl":"","layerGroups":[{"name":"Malformed","visible":false}],"layers":[{"id":"p","name":"Point","type":"geojson","geojson":{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{}}]}}]}).to_string(); s.run("project.importGeolibre", &json!({"text":text})).unwrap(); assert_eq!(s.map().layers.len(), 1); assert!(s.map().groups.iter().all(|g| g.children.is_empty())); let id = s.map().layers[0].id; assert!(s.map().group_of(id).is_none()); } /// Declared COGs are raster layers read in place even without a TIFF suffix, kept when their server /// can't be reached at import. #[test] fn geolibre_extensionless_cog_is_a_raster_read_in_place() { let mut s = world(); let text = json!({"version":"0.2.0","name":"COG","mapView":{},"basemapStyleUrl":"","layerGroups":[{"id":"g","name":"Folder","opacity":0.5}],"layers":[{"id":"c","name":"Coverage","type":"cog","groupId":"g","opacity":0.6,"source":{"url":"http://127.0.0.1:9/extensionless-coverage"}}]}).to_string(); let out = s.run("project.importGeolibre", &json!({"text":text})).unwrap(); assert_eq!(out["layers"], json!(["Coverage"]), "{out}"); // A raster layer read in place, kept although its server can't be reached now (it says why). assert!(s.map().tiles.is_empty()); let l = s.map().layers.iter().find(|l| l.name == "Coverage").unwrap(); let rl = l.raster.as_ref().unwrap(); assert!(rl.in_place && rl.data.is_none() && rl.error.is_some(), "{:?}", rl.error); // Drawn at its own opacity in its folder's: 0.6 × 0.5. assert!((s.map().layer_opacity(l) - 0.3).abs() < 1e-6, "{}", s.map().layer_opacity(l)); assert!(s.take_url_requests().is_empty()); } /// Public fetches cannot smuggle importer callbacks past the dedicated user-only command. #[test] fn geolibre_callback_state_is_rejected_by_public_url_fetches() { let mut s = world(); for key in ["geolibreProject", "geolibreLayer", "geolibreZarr"] { let mut p = json!({"url":"http://127.0.0.1:9/data.geojson"}); p[key] = json!(true); assert!(s.run("layer.addUrl", &p).unwrap_err().contains("state is internal")); } assert!(crate::cmd::plugins::user_only("layer.addDownload")); assert!(s.take_url_requests().is_empty()); } /// A non-tiled TIFF must remain a grouped raster after the native COG probe falls back. #[cfg(not(target_arch = "wasm32"))] #[test] fn geolibre_remote_plain_tiff_folder_opacity_is_not_baked_twice() { use std::io::{Read, Write}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; let raster = rustgis_data::raster::ascii::read( "ncols 2\nnrows 2\nxllcorner 0\nyllcorner 0\ncellsize 1\n1 2\n3 4\n", "plain", Some(rustgis_data::raster::Crs::LonLat), ) .unwrap(); let bytes = rustgis_data::raster::geotiff::write(&raster, 0).unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let url = format!("http://{}/plain.tif", listener.local_addr().unwrap()); let stop = Arc::new(AtomicBool::new(false)); let shutdown = stop.clone(); let server = std::thread::spawn(move || { while !shutdown.load(Ordering::Relaxed) { let (mut stream, _) = match listener.accept() { Ok(connection) => connection, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(std::time::Duration::from_millis(2)); continue; } Err(_) => break, }; // Windows hands out accepted sockets in the listener's nonblocking mode: the read // would come back at once, and closing on the unread request resets the connection. stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); let mut input = [0; 8192]; let n = stream.read(&mut input).unwrap_or(0); let request = String::from_utf8_lossy(&input[..n]).to_ascii_lowercase(); let range = request.lines().find_map(|line| line.strip_prefix("range: bytes=")); let (start, end) = range .and_then(|r| r.split_once('-')) .map(|(a, b)| (a.parse::().unwrap_or(0), b.parse::().unwrap_or(bytes.len() - 1).min(bytes.len() - 1))) .unwrap_or((0, bytes.len() - 1)); let body = bytes.get(start..=end).unwrap_or_default(); let status = if range.is_some() { "206 Partial Content" } else { "200 OK" }; let head = format!( "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{}\r\nConnection: close\r\n\r\n", body.len(), bytes.len() ); let _ = stream.write_all(head.as_bytes()); let _ = stream.write_all(body); } }); let mut s = world(); let text = json!({"version":"0.2.0","name":"Plain TIFF","mapView":{},"basemapStyleUrl":"","layerGroups":[{"id":"g","name":"Folder","opacity":0.5}],"layers":[{"id":"r","name":"Raster","type":"raster","groupId":"g","opacity":0.6,"source":{"url":url}}]}).to_string(); let result = s.run("project.importGeolibre", &json!({"text":text})); stop.store(true, Ordering::Relaxed); server.join().unwrap(); let out = result.unwrap(); assert_eq!(out["layers"], json!(["Raster"]), "{out}"); let layer = s.map().layer_by_name("Raster").unwrap(); assert!(layer.is_raster()); assert!((layer.transparency - 40.0).abs() < 1e-5); let group = s.map().group_of(layer.id).unwrap(); assert_eq!(group.transparency, 50.0); assert!(s.map().tiles.is_empty()); } /// Tile names need not be unique; parser notes on skipped sources must not inflate the count. #[test] fn geolibre_summary_counts_completed_records_instead_of_note_names() { let projects = [ ( json!([ {"id":"a","name":"Same","type":"xyz","source":{"url":"https://example.org/a/{z}/{x}/{y}.png"}}, {"id":"b","name":"Same","type":"xyz","source":{"url":"https://example.org/b/{z}/{x}/{y}.png"}} ]), "2 drawn differently, 0 left out", ), ( json!([ {"id":"good","name":"Good","type":"geojson","geojson":{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{}}]}}, {"id":"bad","name":"Dropped","type":"deckgl-viz","beforeId":"unsupported-anchor"} ]), "0 drawn differently, 1 left out", ), ]; for (layers, summary) in projects { let mut s = world(); let text = json!({"version":"0.2.0","name":"Summary","mapView":{},"basemapStyleUrl":"","layers":layers}).to_string(); let out = s.run("project.importGeolibre", &json!({"text":text})).unwrap(); let expected = if summary.starts_with("2 ") { 2 } else { 1 }; assert_eq!(out["layers"].as_array().unwrap().len(), expected, "{out}"); let notices: Vec<_> = s.notices.iter().map(|n| n.text.as_str()).collect(); assert!(notices.iter().any(|n| n.contains(summary)), "expected {summary}, got {notices:?}"); } } #[test] fn geolibre_pending_layers_cannot_be_saved_as_completed_data() { let mut s = world(); let completed = s.map().layers.len(); let mut ds = crate::data::Dataset::new("Pending", crate::data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:save-test".into()); let id = s.map_mut().add_dataset(ds); assert!(s.project.saved_text(None).unwrap_err().contains("still downloading")); for command in ["project.package", "map.package"] { assert!(s.run(command, &json!({"path":"unused-pending-package.zip"})).unwrap_err().contains("still downloading")); } assert!(crate::cmd::items::item_bytes(&mut s, "template", &json!({})).unwrap_err().contains("still downloading")); let recovered = s.project.clone().recovery_text().unwrap(); assert!(!recovered.contains("geolibre-pending:")); let recovered: crate::project::Project = serde_json::from_str(&recovered).unwrap(); assert_eq!(recovered.maps[recovered.active_map].layers.len(), completed); assert!(s.map().layer(id).is_some()); s.map_mut().layer_mut(id).unwrap().dataset.source = None; assert!(s.project.saved_text(None).is_ok()); assert!(s.project.clone().recovery_text().is_ok()); let mut tile = rustgis_map::tiles::TileLayer::new("Pending archive", rustgis_map::tiles::TileKind::MbTiles, "upload:pending.mbtiles"); tile.id = "geolibre-pending:tile-save-test".into(); s.map_mut().tiles.push(tile); assert!(s.project.saved_text(None).unwrap_err().contains("still downloading")); for command in ["project.package", "map.package"] { assert!(s.run(command, &json!({"path":"unused-pending-package.zip"})).unwrap_err().contains("still downloading")); } assert!(crate::cmd::items::item_bytes(&mut s, "template", &json!({})).unwrap_err().contains("still downloading")); let recovered = s.project.clone().recovery_text().unwrap(); assert!(!recovered.contains("geolibre-pending:")); let recovered: crate::project::Project = serde_json::from_str(&recovered).unwrap(); assert!(recovered.maps[recovered.active_map].tiles.iter().all(|tile| tile.name != "Pending archive")); assert!(s.map().tiles.iter().any(|tile| tile.id.starts_with("geolibre-pending:"))); } #[test] fn geolibre_text_cannot_read_local_files_without_explicit_directory() { let dir = std::env::temp_dir().join(format!("rustgis-geolibre-local-trust-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("local.geojson"); std::fs::write( &path, br#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{}}]}"#, ) .unwrap(); let text = json!({"version":"0.2.0","name":"Trust","mapView":{},"basemapStyleUrl":"","layers":[{"name":"Local","type":"geojson","sourcePath":path}]}).to_string(); let mut s = world(); let out = s.run("project.importGeolibre", &json!({"text":text})).unwrap(); assert_eq!(out["layers"], json!([])); let out = s.run("project.importGeolibre", &json!({"text":text,"dir":dir})).unwrap(); assert_eq!(out["layers"], json!(["Local"])); std::fs::remove_dir_all(dir).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn geolibre_archive_completion_preserves_pending_tile_edits() { let dir = std::env::temp_dir().join(format!("rustgis-geolibre-archive-edits-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("archive.mbtiles"); let _ = std::fs::remove_file(&path); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch("CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('format','png'),('minzoom','0'),('maxzoom','0');").unwrap(); drop(db); let mut s = world(); let token = "geolibre-pending:archive-edits"; let mut tile = rustgis_map::tiles::TileLayer::new("Original", rustgis_map::tiles::TileKind::MbTiles, "upload:pending-edits.mbtiles"); tile.id = token.into(); tile.visible = true; tile.opacity = 0.5; s.map_mut().tiles.insert(0, tile); s.run("map.setTiles", &json!({"index":0,"visible":false,"opacity":0.25,"rename":"Edited","blend":"multiply"})).unwrap(); let download = s.download_started(); s.download_landed(download, std::fs::read(path).unwrap()); let out = s.run("layer.addDownload", &json!({"id":download,"url":"https://example.org/archive.mbtiles","geolibreLayer":{"token":token,"record":{"type":"mbtiles","visible":true,"opacity":0.5}}})).unwrap(); let tile = &s.map().tiles[out["index"].as_u64().unwrap() as usize]; assert_eq!(tile.name, "Edited"); assert!(!tile.visible); assert_eq!(tile.opacity, 0.25); assert_eq!(tile.blend.code(), "multiply"); assert!(!tile.id.starts_with("geolibre-pending:")); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn geolibre_item_exports_check_only_the_selected_map_or_layer() { let mut s = world(); let clean = s.map().name.clone(); s.run("map.new", &json!({"name":"Pending map"})).unwrap(); let mut ds = crate::data::Dataset::new("Pending", crate::data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:export-test".into()); let pending = s.map_mut().add_dataset(ds); assert!(crate::cmd::items::item_bytes(&mut s, "map", &json!({})).unwrap_err().contains("still downloading")); assert!(crate::cmd::items::item_bytes(&mut s, "layer", &json!({"layer":pending})).unwrap_err().contains("still downloading")); assert!(crate::cmd::items::item_bytes(&mut s, "map", &json!({"map":clean})).is_ok()); s.project.active_map = 0; for kind in ["mapPackage", "mobilePackage"] { assert!(crate::cmd::items::item_bytes(&mut s, kind, &json!({})).is_ok()); } assert!(crate::cmd::items::item_bytes(&mut s, "projectPackage", &json!({})).unwrap_err().contains("still downloading")); s.project.active_map = 1; for kind in ["mapPackage", "mobilePackage"] { assert!(crate::cmd::items::item_bytes(&mut s, kind, &json!({})).unwrap_err().contains("still downloading")); } s.map_mut().layer_mut(pending).unwrap().visible = false; assert!(crate::cmd::items::item_bytes(&mut s, "mobilePackage", &json!({})).is_ok()); assert!(crate::cmd::items::item_bytes(&mut s, "mapPackage", &json!({})).is_err()); } #[test] fn geolibre_file_pending_tokens_are_rejected_without_mutating_live_work() { let mut s = world(); let mut ds = crate::data::Dataset::new("Forged pending", crate::data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:forged".into()); let mut map = rustgis_map::Map::new("Forged map"); let id = map.add_dataset(ds); let layer = serde_json::to_string(map.layer(id).unwrap()).unwrap(); assert!(crate::cmd::layer::add_layer_text(&mut s, &layer).is_err()); let count = s.project.maps.len(); assert!(s.run("map.import", &json!({"text":serde_json::to_string(&map).unwrap()})).is_err()); assert_eq!(s.project.maps.len(), count); let mut project = crate::project::Project::default(); project.maps = vec![map]; assert!(crate::project::Project::from_json(&serde_json::to_string(&project).unwrap()).is_err()); project.maps[0].layers[0].dataset.source = None; let mut tile = rustgis_map::tiles::TileLayer::new("Forged archive", rustgis_map::tiles::TileKind::MbTiles, "upload:forged.mbtiles"); tile.id = "geolibre-pending:forged-tile".into(); project.maps[0].tiles.push(tile); assert!(crate::project::Project::from_json(&serde_json::to_string(&project).unwrap()).is_err()); assert!(s.project.saved_text(None).is_ok()); } #[test] fn geolibre_offline_snapshots_cannot_persist_or_restore_pending_downloads() { let mut s = world(); s.run("offline.download", &json!({"name":"Completed","extent":[-180,-80,180,80]})).unwrap(); let mut ds = crate::data::Dataset::new("Pending", crate::data::GeometryType::Point, Vec::new()); ds.source = Some("geolibre-pending:offline-test".into()); let id = s.map_mut().add_dataset(ds.clone()); assert!(s.run("offline.download", &json!({"name":"Pending","extent":[-180,-80,180,80]})).unwrap_err().contains("still downloading")); assert_eq!(s.project.offline_areas.len(), 1); assert!(s.run("offline.sync", &json!({"name":"Completed"})).unwrap_err().contains("still downloading")); s.map_mut().layer_mut(id).unwrap().dataset.source = None; let area = &mut s.project.offline_areas[0]; area.data.add_dataset(ds); let raw = serde_json::to_string(&s.project).unwrap(); assert!(crate::project::Project::from_json(&raw).is_err()); assert!(s.project.saved_text(None).is_err()); assert!(s.run("offline.open", &json!({"name":"Completed"})).is_err()); let recovered: crate::project::Project = serde_json::from_str(&s.project.clone().recovery_text().unwrap()).unwrap(); assert!(recovered.offline_areas[0].data.layers.iter().all(|l| l.dataset.source.as_deref() != Some("geolibre-pending:offline-test"))); assert!(!recovered.offline_areas[0].data.layers.is_empty()); } #[test] fn merge_reads_complete_streamed_inputs_and_supports_undo() { let path = grid_fgb("merge", 3); let mut s = Session::new(Project::default()); let loaded = s.run("layer.addData", &json!({"path":path,"stream":true})).unwrap(); let id = loaded["layer"].as_u64().unwrap() as u32; let name = s.map().layer(id).unwrap().name.clone(); let total = s.map().layer(id).unwrap().load_all().unwrap().features.len(); assert_eq!(total, 9); // A streamed map layer only holds its current window, not every source feature. s.map_mut().layer_mut(id).unwrap().dataset.features.truncate(1); assert!(s.map().layer(id).unwrap().dataset.features.len() < total); let mut other = rustgis_data::Dataset::new("Other", rustgis_data::GeometryType::Point, vec![]); other.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![]); s.map_mut().add_dataset(other); for inputs in [json!(format!("{name};Other")), json!([name, "Other"]), json!([path.to_string_lossy(), "Other"])] { let before = s.map().layers.len(); s.run("gp.run", &json!({"tool":"merge","params":{"inputs":inputs,"output":"Merged"}})).unwrap(); assert_eq!(s.map().layer_by_name("Merged").unwrap().dataset.features.len(), total + 1); assert_eq!(s.map().layers.len(), before + 1); assert!(s.undo()); assert!(s.map().layer_by_name("Merged").is_none()); assert!(s.map().layer(id).unwrap().is_streamed()); } std::fs::remove_file(path).unwrap(); } #[test] fn merge_file_inputs_use_the_existing_file_access_check() { let s = Session::new(Project::default()); for tool in ["merge", "Merge"] { let reach = crate::cmd::plugins::reaches( s.registry.get("gp.run").unwrap(), &json!({"tool":tool,"params":{"inputs":["survey.geojson","observations.geojson"]}}), ); assert_eq!(reach, Some(crate::cmd::plugins::Reach::Files)); } } #[test] fn merge_keeps_import_warnings_from_direct_file_inputs() { let path = std::env::temp_dir().join(format!("rustgis-merge-warnings-{}.geojson", std::process::id())); std::fs::write(&path, r#"{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{}},{"type":"Feature","geometry":{"type":"Point","coordinates":["bad",2]},"properties":{}}]}"#).unwrap(); let mut s = Session::new(Project::default()); let mut ds = rustgis_data::Dataset::new("Other", rustgis_data::GeometryType::Point, vec![]); ds.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(0.0, 0.0))), vec![]); s.map_mut().add_dataset(ds); let mark = s.notice_mark(); let result = s.run("gp.run", &json!({"tool":"merge","params":{"inputs":[path.to_string_lossy(),"Other"]}})).unwrap(); let id = result["layer"].as_u64().unwrap() as u32; assert_eq!(s.map().layer(id).unwrap().dataset.features.len(), 2); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("unreadable geometry"))); assert!(s.project.gp_history.last().unwrap().messages.iter().any(|s| s.contains("unreadable geometry"))); std::fs::remove_file(path).unwrap(); } #[cfg(target_os = "linux")] #[test] fn merge_distinguishes_case_sensitive_file_paths() { let dir = std::env::temp_dir().join(format!("rustgis-merge-case-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let a = dir.join("a.geojson"); let b = dir.join("A.geojson"); for (path, x) in [(&a, 1), (&b, 2)] { std::fs::write(path, json!({"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[x,0]},"properties":{}}]}).to_string()).unwrap(); } let mut s = world(); let result = s.run("gp.run", &json!({"tool":"merge","params":{"inputs":[a.to_string_lossy(),b.to_string_lossy()]}})).unwrap(); let id = result["layer"].as_u64().unwrap() as u32; let ds = &s.map().layer(id).unwrap().dataset; let coordinates: Vec = ds .features .iter() .map(|f| match f.geometry.as_ref().unwrap() { rustgis_data::Geometry::Point(c) => c.x, _ => panic!("expected point"), }) .collect(); assert_eq!(coordinates, [1.0, 2.0]); std::fs::remove_dir_all(dir).unwrap(); } /// A box given −180° to 180° over a file stored 0° to 360° is shifted into its range; one across /// the file's seam is refused (#1204). #[test] fn a_window_box_takes_the_files_longitudes() { use crate::cmd::layer::box_in_file_longitudes as fit; assert_eq!(fit(-10.0, -1.0, 0.0, 360.0).unwrap(), (350.0, 359.0)); assert_eq!(fit(10.0, 20.0, 0.0, 360.0).unwrap(), (10.0, 20.0)); assert!(fit(-10.0, 10.0, 0.0, 360.0).unwrap_err().contains("seam at 0°")); // A file stored −180° to 180°, or a part of the globe, is left as it is. assert_eq!(fit(-170.0, -160.0, -180.0, 180.0).unwrap(), (-170.0, -160.0)); assert_eq!(fit(10.25, 10.5, 10.0, 11.0).unwrap(), (10.25, 10.5)); assert_eq!(fit(12.0, 13.0, 10.0, 11.0).unwrap(), (12.0, 13.0), "off the file: refused later as such"); // A file stored 0° to 358.4° (1024 cells of 0.35°): a box across 0° reaches both of its ends, // and is refused, not cut to the larger part; one on a side of it is taken. assert!(fit(-10.0, 10.0, 0.0, 358.4).unwrap_err().contains("seam at 0°")); assert_eq!(fit(-10.0, -2.0, 0.0, 358.4).unwrap(), (350.0, 358.0)); assert_eq!(fit(1.0, 10.0, 0.0, 358.4).unwrap(), (1.0, 10.0)); // A box of the whole world takes all of the file, whatever its range. assert_eq!(fit(-180.0, 180.0, 0.0, 360.0).unwrap(), (0.0, 360.0)); assert_eq!(fit(-180.0, 180.0, -180.0, 180.0).unwrap(), (-180.0, 180.0)); assert_eq!(fit(-180.0, 180.0, 0.0, 358.4).unwrap(), (0.0, 360.0), "past the east edge: cut, and said"); // A file whose columns start two turns away (720° to 1080°): the box is taken there too, and // its seam is the file's. assert_eq!(fit(-10.0, -1.0, 720.0, 1080.0).unwrap(), (1070.0, 1079.0)); assert!(fit(-10.0, 10.0, 720.0, 1080.0).unwrap_err().contains("seam at 720°")); } /// A window's bands are started within one wait for them all: the first always, the others /// until it is up (#1204). #[test] fn a_windows_bands_are_started_within_its_wait() { use crate::cmd::layer::band_in_time; use std::time::Duration; let wait = Duration::from_secs(300); assert!(band_in_time(0, 4, Duration::from_secs(400), wait).is_ok(), "the first band is always read"); assert!(band_in_time(3, 4, Duration::from_secs(299), wait).is_ok()); let e = band_in_time(2, 4, Duration::from_secs(301), wait).unwrap_err(); assert_eq!(e, "2 of 4 bands read in 5 minutes, so the window is given up: take a smaller box or a larger cellSize"); } /// A script or model the desktop app runs off the frame, as a plugin's command, leaves a window to /// the app: answered pending, and queued, not refused (#1330). #[cfg(not(target_arch = "wasm32"))] #[test] fn a_script_run_by_the_app_leaves_a_window_to_it() { let mut s = world(); let p = json!({"url": "https://x.org/scene.tif", "bbox": [10.25, 45.5, 10.5, 45.75]}); let answer = s.with_deferred_downloads(|s| s.run("layer.addUrl", &p)).unwrap(); assert_eq!(answer["pending"], true, "{answer}"); let asked = s.take_url_requests(); assert_eq!(asked.len(), 1); assert_eq!(asked[0].params()["bbox"], p["bbox"]); } /// A command run again that asks for windows of the view in two views is answered each by the /// window asked in that view, not twice by the first (#1330). #[cfg(not(target_arch = "wasm32"))] #[test] fn a_command_run_again_gets_each_view_s_own_window() { let mut s = world(); let p = json!({"url": "https://x.org/scene.tif", "bbox": "view"}); let asked = s.asked_for(&p); let mut views = Vec::new(); for (extent, marker) in [([10.0, 45.0, 11.0, 46.0], "A"), ([20.0, 45.0, 21.0, 46.0], "B")] { s.run("map.zoomToExtent", &json!({"extent": extent})).unwrap(); let e = crate::cmd::workspace::view_extent(&s); let view = [e.min_x, e.min_y, e.max_x, e.max_y]; s.rerun_landed.push((Session::with_view(&asked, Some(view)), Ok(json!({"marker": marker})), Default::default())); views.push(extent); } let answer = |s: &mut Session, extent: [f64; 4]| { s.run("map.zoomToExtent", &json!({"extent": extent})).unwrap(); s.with_deferred_downloads(|s| s.run("layer.addUrl", &p)).unwrap()["marker"].clone() }; assert_eq!(answer(&mut s, views[1]), "B"); assert_eq!(answer(&mut s, views[0]), "A"); // A caller can't write the key the view is told apart by. let written = s.asked_for(&json!({"url": "https://x.org/scene.tif", "bbox": "view", "viewAsked": [0, 0, 1, 1]})); assert_eq!(written, asked); } /// A request for the view's window joins one queued only when it was asked in the same view: /// asked in another, it is another request, read for its own view (#1330). #[test] fn a_window_of_the_view_joins_only_one_asked_in_the_same_view() { let mut s = Session::new(Project::default()); let p = json!({"url": "https://x.org/scene.tif", "bbox": "view"}); let (a, b) = ([10.0, 45.0, 11.0, 46.0], [20.0, 45.0, 21.0, 46.0]); for view in [Some(a), Some(a), Some(b), None] { s.window_view = view; s.request_url(&p).unwrap(); s.window_view = None; } let views: Vec> = s.take_url_requests().iter().map(crate::UrlRequest::view_asked).collect(); assert_eq!(views, [Some(a), Some(b), None], "the same view's asked once"); assert_eq!(s.url_waits.views.len(), 2, "kept by request number"); } /// A window of the view that is being read joins a command asking for it again in the same view; /// asked in another view, or with a box, it is another request (#1330). #[cfg(not(target_arch = "wasm32"))] #[test] fn a_window_of_the_view_being_read_joins_only_the_same_view() { let mut s = world(); plugin("eng-window-flight", &["edit", "network"]); let ask = |s: &mut Session, bbox: Value| { let p = json!({"op": "layer.addUrl", "params": {"url": "https://x.org/scene.tif", "bbox": bbox, "name": "Window"}}); s.run_leaving_downloads("plugin:eng-window-flight/f", &p).unwrap(); s.take_url_requests() }; s.run("map.zoomToExtent", &json!({"extent": [10.0, 45.0, 11.0, 46.0]})).unwrap(); let first = ask(&mut s, json!("view")); assert_eq!(first.len(), 1); s.download_started_for(&first[0]).unwrap(); // While it is read: the same view joins it. assert!(ask(&mut s, json!("view")).is_empty(), "the same view's window is read once"); // Another view: another request, for that view. s.run("map.zoomToExtent", &json!({"extent": [20.0, 45.0, 21.0, 46.0]})).unwrap(); let other = ask(&mut s, json!("view")); assert_eq!(other.len(), 1); assert!(other[0].view_asked().is_some() && other[0].view_asked() != first[0].view_asked()); // A box: another request, with no view. let boxed = ask(&mut s, json!([10.25, 45.5, 10.5, 45.75])); assert_eq!(boxed.len(), 1); assert_eq!(boxed[0].view_asked(), None); } /// A plugin's command asking for the view's window is read for the view it was asked in, though the /// app reads it on a later frame, when the map may show another (#1330): a command run again after /// a download sees the view it first ran with only while it runs. The box is kept beside the /// request, not in what it asks, so the command run again is answered the raster added. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_window_asked_for_the_view_keeps_the_view_it_was_asked_in() { use crate::cmd::layer::{read_cog_window, window_asked, window_asked_in}; use rustgis_data::raster::cog::example; let (base, _) = serve_ranges(example(1024, 256)); let mut s = world(); plugin("eng-window-view", &["edit", "network"]); let layers = s.map().layers.len(); s.run("map.zoomToExtent", &json!({"extent": [10.3, 45.55, 10.45, 45.7]})).unwrap(); let seen = crate::cmd::workspace::view_extent(&s); let p = json!({"op": "layer.addUrl", "params": {"url": format!("{base}/scene.tif"), "bbox": "view", "name": "Window"}}); s.run_leaving_downloads("plugin:eng-window-view/f", &p).unwrap(); let asked = s.take_url_requests(); assert_eq!(asked.len(), 1); assert_eq!(asked[0].params()["bbox"], "view", "what it asks is as it asked it"); let close = |a: [f64; 4], b: rustgis_geom::Extent| a.iter().zip([b.min_x, b.min_y, b.max_x, b.max_y]).all(|(a, b)| (a - b).abs() < 1e-9); s.run("map.zoomToExtent", &json!({"extent": [-50.0, -20.0, -40.0, -10.0]})).unwrap(); let window = window_asked_in(&s, asked[0].params(), asked[0].view_asked()).unwrap(); assert!(close(window.bbox(), seen), "{:?} {seen:?}", window.bbox()); // Without the request's box, `"view"` is the view now. let now = crate::cmd::workspace::view_extent(&s); assert!(close(window_asked(&s, asked[0].params()).unwrap().bbox(), now)); // Read and added: the command run again is answered the raster added, and asks nothing more. let id = s.download_started_for(&asked[0]).unwrap(); assert!(s.download_landed_window(id, read_cog_window(&window).unwrap())); let mut add = asked[0].params().clone(); add["id"] = json!(id); add["window"] = json!({"bbox": "view"}); s.complete_download(id, &add).unwrap(); let added = s.map().layer_by_name("Window").filter(|l| l.is_raster()).unwrap().id; let ran = s.poll_plugin_retries(); assert_eq!(ran.len(), 1, "{ran:?}"); assert_eq!(ran[0].1.as_ref().unwrap()["layer"], json!(added), "{ran:?}"); assert_eq!(s.map().layers.len(), layers + 1, "added once"); assert!(s.take_url_requests().is_empty() && !s.plugin_retries_waiting()); } /// A plugin's command asking for a window runs again once the app has read it and added it, and /// its `layer.addUrl` then answers the raster added (#1330): the window it lands with is the one /// the command asked for, so its claim takes it. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_plugin_command_runs_again_when_its_window_is_added() { use crate::cmd::layer::{read_cog_window, window_asked}; use rustgis_data::raster::cog::example; let (base, _) = serve_ranges(example(1024, 256)); let mut s = world(); plugin("eng-window", &["edit", "network"]); let layers = s.map().layers.len(); let url = format!("{base}/scene.tif"); let bbox = json!([10.25, 45.5, 10.5, 45.75]); let p = json!({"op": "layer.addUrl", "params": {"url": url, "bbox": bbox, "name": "Window"}}); s.run_leaving_downloads("plugin:eng-window/f", &p).unwrap(); let asked = s.take_url_requests(); assert_eq!(asked.len(), 1); assert_eq!(s.map().layers.len(), layers, "nothing added while it is read"); // The app reads it apart from the session and adds it with the request's number, and with // what a task needs to read it again, as `start_window_read` does. let id = s.download_started_for(&asked[0]).unwrap(); let read = read_cog_window(&window_asked(&s, asked[0].params()).unwrap()).unwrap(); assert!(s.download_landed_window(id, read)); let mut add = asked[0].params().clone(); add["id"] = json!(id); add["window"] = json!({"bbox": bbox}); s.complete_download(id, &add).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); let added = s.map().layer_by_name("Window").filter(|l| l.is_raster()).unwrap().id; let ran = s.poll_plugin_retries(); assert_eq!(ran.len(), 1, "{ran:?}"); let (cmd, answer) = &ran[0]; assert_eq!(cmd, "plugin:eng-window/f"); assert_eq!(answer.as_ref().unwrap()["layer"], json!(added), "{answer:?}"); assert_eq!(s.map().layers.len(), layers + 1, "added once"); assert!(s.take_url_requests().is_empty() && !s.plugin_retries_waiting()); } /// A window's read needs no session: on a thread, as the app reads it, then added when it lands /// (#1330). What can't be a window is refused before the read starts, and an error names the /// address without its keys. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_cog_windows_read_is_apart_from_adding_it() { use crate::cmd::layer::{read_cog_window, window_asked}; use rustgis_data::raster::cog::example; let (base, _) = serve_ranges(example(1024, 256)); let mut s = world(); let url = format!("{base}/scene.tif?token=hidden-1330"); let p = json!({"url": url, "bbox": [10.25, 45.5, 10.5, 45.75], "name": "Window"}); let asked = window_asked(&s, &p).unwrap(); let layers = s.map().layers.len(); let read = std::thread::spawn(move || read_cog_window(&asked)).join().unwrap().unwrap(); assert_eq!(s.map().layers.len(), layers, "nothing added yet"); let id = s.download_started(); assert!(s.download_landed_window(id, read)); let mark = s.notice_mark(); let r = s.run("layer.addDownload", &json!({"id": id, "url": url, "name": "Window"})).unwrap(); assert_eq!(r["window"], json!({"columns": [256, 512], "rows": [256, 512], "level": 0, "cellSize": 111_320.0 / 1024.0})); assert!(s.landed_windows.is_empty()); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.starts_with("Window: a 256 × 256 window of the full image")), "{said:?}"); let w = s.map().layer_by_name("Window").and_then(|l| l.raster.as_ref()?.data.clone()).unwrap(); assert_eq!((w.value(0, 0, 0), w.value(0, 10, 5)), (Some(0.0), Some(15.0))); assert_eq!(s.map().layer(r["layer"].as_u64().unwrap() as u32).map(|l| l.name.as_str()), Some("Window")); // A download that isn't a window isn't added as one. let id = s.download_started(); assert!(s.download_landed(id, b"{}".to_vec())); let e = s.run("layer.addDownload", &json!({"id": id, "url": url, "window": {"bbox": [1, 2, 3, 4]}})).unwrap_err(); assert!(e.contains("\"window\" is for") && !e.contains("hidden-1330"), "{e}"); // A project opened while it was read: it isn't kept to be added. let read = read_cog_window(&window_asked(&s, &p).unwrap()).unwrap(); let id = s.download_started(); s.run("project.new", &json!({})).unwrap(); assert!(!s.download_landed_window(id, read)); assert!(s.landed_windows.is_empty()); // Refused as the command refuses them, without the address's keys. let bad = |more: Value| { let mut q = json!({"url": "https://x.org/a.tif?token=hidden-1330", "bbox": [1, 2, 3, 4]}); q.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); window_asked(&s, &q).err().unwrap() }; for (more, why) in [ (json!({"bbox": "here"}), "bbox is"), (json!({"cellSize": -1}), "cellSize is"), (json!({"whole": true}), "leave out"), (json!({"crs": "EPSG:4326"}), "doesn't read crs"), (json!({"url": "https://x.org/a.geojson?token=hidden-1330"}), "names a .geojson file"), (json!({"url": "C:/data/a.tif"}), "address (Add Data opens files)"), (json!({"geolibreProject": true}), "GeoLibre download state"), (json!({"geolibreLayer": 1}), "GeoLibre download state"), (json!({"geolibreZarr": false}), "GeoLibre download state"), (json!({"service": true}), "doesn't read service"), (json!({"coverage": true}), "doesn't read coverage"), (json!({"bounds": [1, 2, 3, 4]}), "doesn't read bounds"), (json!({"cube": true}), "doesn't read cube"), ] { let e = bad(more); assert!(e.contains(why) && !e.contains("hidden-1330"), "{why}: {e}"); } // `"cube": false` asks for nothing. assert!(window_asked(&s, &json!({"url": "https://x.org/a.tif", "bbox": [1, 2, 3, 4], "cube": false})).is_ok()); // A file that can't be read: said by the read, without the address's keys. let (gone, _) = serve_ranges(b"not a tiff at all".to_vec()); let asked = window_asked(&s, &json!({"url": format!("{gone}/a.tif?token=hidden-1330"), "bbox": [1, 2, 3, 4]})).unwrap(); let e = read_cog_window(&asked).err().unwrap(); assert!(e.contains("/a.tif") && !e.contains("hidden-1330"), "{e}"); } /// `layer.addUrl` with `"bbox"` (#1204): a window of a cloud-optimised GeoTIFF, read by range as a /// raster layer, at full resolution or at the overview a cell size asks for; a palette file's /// colours and class names come with it; the window is kept in a saved project, class names too. #[test] fn a_cog_window_comes_in_as_a_raster() { use rustgis_data::raster::ColorHint; use rustgis_data::raster::cog::{Example, example, example_with}; // 1024 pixels over 10°–11° E, 45°–46° N, in tiles of 256, with overviews of 512 and 256; // pixel (x, y) is (x + y) % 256. let (base, sent) = serve_ranges(example(1024, 256)); let mut s = world(); let raster = |s: &Session, name: &str| s.map().layer_by_name(name).and_then(|l| l.raster.as_ref()?.data.clone()).unwrap(); let mark0 = s.notice_mark(); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "name": "Window"})).unwrap(); assert_eq!(r["window"], json!({"columns": [256, 512], "rows": [256, 512], "level": 0, "cellSize": 111_320.0 / 1024.0})); let w = raster(&s, "Window"); assert_eq!((w.width, w.height), (256, 256)); assert_eq!((w.transform.origin_x, w.transform.origin_y), (10.25, 45.75)); assert_eq!((w.value(0, 0, 0), w.value(0, 10, 5)), (Some(0.0), Some(15.0))); // Only the window's tile was read (one of the full image's sixteen), with the header and the // coarsest overview opening the file reads: 192 KB of the 1.4 MB. let read = sent.load(std::sync::atomic::Ordering::Relaxed); assert!(read <= 3 * 256 * 256, "{read} bytes"); // Explicitly keeping the view also applies to the range-window path added on main. let mut empty = Session::new(Project::default()); let before = empty.map().camera; empty.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "zoom": false})).unwrap(); assert_eq!(empty.map().camera, before, "zoom:false keeps an empty map's view for a COG window"); // A cell size of 300 m takes the 512-pixel overview (cells of 217 m), not the 256 (435 m). let r = s .run( "layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "cellSize": 300, "name": "Coarser"}), ) .unwrap(); assert_eq!((r["window"]["level"].clone(), r["window"]["columns"].clone()), (json!(1), json!([128, 256]))); let c = raster(&s, "Coarser"); assert_eq!((c.width, c.value(0, 1, 0)), (128, Some(2.0))); // A cell size finer than the full image's (108.7 m) reads the full image, and says so. let mark = s.notice_mark(); let r = s .run( "layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "cellSize": 10, "name": "Finer"}), ) .unwrap(); assert_eq!(r["window"]["level"], json!(0)); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("cellSize 10 is finer than the file's finest cells (108.7 m)"))); // A box over the file's edge is cut to the file, and says so; a box within it says nothing of // the kind. One off it, or a wrong one, is refused. let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.75, 45.75, 12.0, 47.0], "name": "Edge"})).unwrap(); assert_eq!((raster(&s, "Edge").width, raster(&s, "Edge").height), (256, 256)); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter().any(|n| n == "Edge: the box reaches past the file's east, north edge(s): the window is cut to the file"), "{said:?}" ); let boxed = s.notices_since(mark0).iter().filter(|n| n.text.contains("the box reaches past")).count(); assert_eq!(boxed, 1, "only Edge's box is cut: those within the file say nothing of it"); for (bbox, want) in [ (json!([20.0, 45.0, 21.0, 46.0]), "outside the file"), (json!([10.5, 45.5, 10.25, 45.75]), "bbox is"), (json!([10, 45, 11]), "bbox is"), (json!("everywhere"), "bbox is"), (json!([10.0, 45.0, 500.0, 46.0]), "bbox is"), ] { let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": bbox})).unwrap_err(); assert!(e.contains(want), "{e}"); } let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46], "whole": true})).unwrap_err(); assert!(e.contains("leave out"), "{e}"); let e = s .run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46], "crs": "EPSG:3857", "x": "lon"})) .unwrap_err(); assert!(e.contains("doesn't read x, crs"), "{e}"); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "cellSize": 100})).unwrap_err(); assert!(e.contains("\"cellSize\" goes with \"bbox\""), "{e}"); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46], "dates": ["2024"]})).unwrap_err(); assert!(e.contains("doesn't read dates"), "{e}"); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46], "cellSize": 0})).unwrap_err(); assert!(e.contains("cellSize"), "{e}"); // A window over the most values (300,000 under test) is refused, naming the overview that // fits: the 512-pixel one, cells of 217.4 m; asked for, it comes in. let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46]})).unwrap_err(); assert!(e.contains("1024 × 1024 cells") && e.contains("take \"cellSize\": 218 or more"), "{e}"); let r = s .run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10, 45, 11, 46], "cellSize": 218, "name": "Whole"})) .unwrap(); assert_eq!((r["window"]["level"].clone(), raster(&s, "Whole").width), (json!(1), 512)); // "zoom": true frames the window, though the map held layers already. let r = s .run( "layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "name": "Zoomed", "zoom": true}), ) .unwrap(); assert_eq!(r["zoomed"], true, "{r}"); let c = rustgis_geom::merc_to_lonlat(s.map().camera.center); assert!((c.x - 10.375).abs() < 0.05 && (c.y - 45.625).abs() < 0.05, "{c:?}"); // Framed, not only centred: the view takes the window in and is not much wider. let v = crate::cmd::workspace::view_extent(&s); assert!(v.min_x <= 10.25 && v.max_x >= 10.5 && v.min_y <= 45.5 && v.max_y >= 45.75, "{v:?}"); assert!(v.width() < 1.0 && v.height() < 1.0, "{v:?}"); // "bbox": "view" reads the map's extent: here the view just framed, within the file. let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": "view", "name": "View"})).unwrap(); let (c0, c1) = (((v.min_x - 10.0) * 1024.0).floor() as u32, ((v.max_x - 10.0) * 1024.0).ceil() as u32); let (r0, r1) = (((46.0 - v.max_y) * 1024.0).floor() as u32, ((46.0 - v.min_y) * 1024.0).ceil() as u32); assert_eq!((r["window"]["columns"].clone(), r["window"]["rows"].clone()), (json!([c0, c1]), json!([r0, r1])), "{v:?}"); // A view larger than the file reads the whole file, cut on each side, and says so. s.run("map.zoomToExtent", &json!({"extent": [9.0, 44.0, 12.0, 47.0]})).unwrap(); let mark = s.notice_mark(); let r = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": "view", "cellSize": 218, "name": "Wide"})).unwrap(); assert_eq!(r["window"], json!({"columns": [0, 512], "rows": [0, 512], "level": 1, "cellSize": 111_320.0 / 512.0})); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( said.iter().any(|n| n == "Wide: the box reaches past the file's west, east, south, north edge(s): the window is cut to the file"), "{said:?}" ); // A view past 180° of longitude isn't cut to fit: it is refused. s.run("map.zoomToExtent", &json!({"extent": [170.0, 0.0, 180.0, 10.0]})).unwrap(); s.map_mut().camera.center = rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(180.0, 5.0)); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": "view"})).unwrap_err(); assert!(e.contains("the view reaches past 180°"), "{e}"); // Each band is read: two bands, and no colour table for them. let (two, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 2, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{two}/two.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Two"})).unwrap(); // The project keeps one of them, and says so. let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n == "Two: the project keeps 1 of its 2 bands: 1 band(s) are left out once it is reopened"), "{said:?}"); let w2 = raster(&s, "Two"); assert_eq!((w2.band_count(), w2.value(0, 10, 5), w2.value(1, 10, 5)), (2, Some(15.0), Some(25.0))); assert!(!matches!(w2.color, ColorHint::Palette(_)), "{:?}", w2.color); // A file of more bands than a window reads (8 under test) is refused before any is read. let (nine, nine_sent) = serve_ranges(example_with(&Example { size: 256, tile: 256, bands: 9, ..Example::default() })); let e = s.run("layer.addUrl", &json!({"url": format!("{nine}/nine.tif"), "bbox": [10.0, 45.0, 10.01, 45.01]})).unwrap_err(); assert!(e.ends_with("the file has 9 bands; a window reads 8 at most"), "{e}"); let header = nine_sent.load(std::sync::atomic::Ordering::Relaxed); assert!(header < 256 * 256 * 9 / 4, "only the header was read: {header} bytes"); // A cellSize that isn't a number above 0 is refused, naming the address. let e = s .run("layer.addUrl", &json!({"url": format!("{nine}/nine.tif"), "bbox": [10.0, 45.0, 10.01, 45.01], "cellSize": "90"})) .unwrap_err(); assert_eq!(e, format!("{nine}/nine.tif: cellSize is a size above 0")); // A palette file, as MapBiomas's: its colour table and class names come with the window. let (classes, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{classes}/cover.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Cover"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("left out")), "a clean file says nothing lost"); // Straight edges (a longitude/latitude file) are never left unfollowed. assert!(s.notices_since(mark0).iter().all(|n| !n.text.contains("not followed")), "no window here has a bent edge"); // A file whose colour table is too short, with a class item past a band's values: the // window comes in grey, and says what was left out. let (lossy, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, lossy: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{lossy}/lossy.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Lossy"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.starts_with("Lossy: its colour table has 384 entries")), "{said:?}"); assert!(said.iter().any(|n| n.contains("its GDAL metadata: 1 CLASS_ item(s)")), "{said:?}"); assert!(!matches!(raster(&s, "Lossy").color, ColorHint::Palette(_))); // A colour table on a file of two samples a pixel: the window keeps both bands, without it. let (paired, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 2, classes: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{paired}/paired.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Paired"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.contains("its colour table is left out: the window has 2 bands, a palette has one")), "{said:?}"); let paired = raster(&s, "Paired"); assert!(paired.band_count() == 2 && !matches!(paired.color, ColorHint::Palette(_)), "{:?}", paired.color); // Five bands of 256 × 256 cells are 327,680 values: over the cap (300,000 under test), though // the pixels alone are not. let (five, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, bands: 5, ..Example::default() })); let e = s.run("layer.addUrl", &json!({"url": format!("{five}/five.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Five"})).unwrap_err(); assert!(e.contains("the window is 256 × 256 cells") && e.contains("5 band(s); a window holds 300000 values at most"), "{e}"); // An address that names another kind of file is refused before anything is fetched. let before = sent.load(std::sync::atomic::Ordering::Relaxed); for (url, ext) in [(format!("{base}/roads.geojson"), "geojson"), (format!("{base}/a.FGB?x=1"), "fgb"), (format!("{base}/t.pmtiles"), "pmtiles")] { let e = s.run("layer.addUrl", &json!({"url": url, "bbox": [10.25, 45.5, 10.5, 45.75]})).unwrap_err(); assert!(e.contains(&format!("this address names a .{ext} file")), "{e}"); } assert_eq!(sent.load(std::sync::atomic::Ordering::Relaxed), before, "nothing fetched"); assert_eq!(crate::cmd::layer::address_extension("https://x.org/data/scene.TIF?sig=a.b"), Some("tif".into())); assert_eq!(crate::cmd::layer::address_extension("https://x.org/download"), None); assert_eq!(crate::cmd::layer::address_extension("https://x.org/"), None); assert_eq!(crate::cmd::layer::address_extension("https://data.example.com"), None, "a host isn't a file"); assert_eq!(crate::cmd::layer::address_extension("https://data.example.com?x=a.tif"), None); // Through a saved S3 connection, the address it wraps is the one judged. let wrapped = |inner: &str| rustgis_data::s3_address::bind("", "", inner).unwrap(); let e = s.run("layer.addUrl", &json!({"url": wrapped("https://x.org/roads.geojson"), "bbox": [10.25, 45.5, 10.5, 45.75]})).unwrap_err(); assert!(e.contains("names a .geojson file"), "{e}"); let e = s.run("layer.addUrl", &json!({"url": wrapped("https://data.example.com"), "bbox": [10.25, 45.5, 10.5, 45.75]})).unwrap_err(); assert!(!e.contains("names a .com file"), "{e}"); // A plugin's command run by the app leaves a window to the app (#1330): asked for with its // box and nothing fetched; a box that can't be read is refused as the command runs. let before = sent.load(std::sync::atomic::Ordering::Relaxed); let asked = json!({"url": format!("{base}/scene.tif"), "bbox": [10.25, 45.5, 10.5, 45.75], "name": "Left"}); s.leave_downloads = true; assert_eq!(s.run("layer.addUrl", &asked).unwrap()["pending"], true); let e = s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "bbox": "here"})).unwrap_err(); s.leave_downloads = false; assert!(e.contains("bbox is"), "{e}"); assert_eq!(sent.load(std::sync::atomic::Ordering::Relaxed), before, "nothing fetched"); let taken = s.take_url_requests(); assert_eq!(taken.len(), 1); assert_eq!(taken[0].params()["bbox"], asked["bbox"]); // Byte windows round nothing, and say nothing of it; an Int32 window of 16,777,216 plus the // byte value says how many `f32` rounded: the odd ones, half of its 65,536 cells. assert!(s.notices_since(mark0).iter().all(|n| !n.text.contains("rounded")), "{:?}", s.notices_since(mark0)); let (wide, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, wide: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{wide}/ids.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Ids"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.starts_with("Ids: 32768 values were rounded as the file was read")), "{said:?}"); // Across tiles, only the window's own cells are counted (the four tiles hold 131,072 odd // values; the 256 × 256 window, half of its own). let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{wide}/ids.tif"), "bbox": [10.25, 45.25, 10.75, 45.75], "name": "Across"})).unwrap(); assert_eq!(raster(&s, "Across").rounded, [32768], "{:?}", s.notices_since(mark)); // A Float64 file whose 255s are 1e39: those cells are left out, said apart, and not counted // as rounded (its other values are whole numbers `f32` holds). One a row-column diagonal: 256. let (huge, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, huge: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{huge}/far.tif"), "bbox": [10.0, 45.0, 10.5, 45.5], "name": "Far"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n == "Far: 256 value(s) past the range of 32-bit floats left out: those cells have no value"), "{said:?}"); assert!(said.iter().all(|n| !n.contains("rounded")), "{said:?}"); let far = raster(&s, "Far"); assert!(far.rounded.is_empty()); // Column 0, row 255 of the window is a 255 (1e39): no value, kept out of the stretch; the cell // above it is 254. assert_eq!((far.value(0, 0, 255), far.value(0, 0, 254)), (None, Some(254.0))); // Pixel (1, 0) is 16,777,217, held as 16,777,216, as pixel (0, 0) is. let ids = raster(&s, "Ids"); // Exported, a raster whose second band has class names says the file keeps only the first's. let gt = rustgis_data::raster::GeoTransform { origin_x: 10.0, origin_y: 46.0, pixel_width: 0.25, pixel_height: -0.25 }; let rgb = ColorHint::Rgb { alpha: false }; let mut named = rustgis_data::raster::Raster::new("Named", 4, 4, vec![vec![1.0; 16]; 3], gt, rustgis_data::raster::Crs::LonLat, None, rgb, "Byte") .unwrap(); named.bands[1].classes = vec![String::new(), "Water".into()]; let named_bytes = rustgis_data::raster::geotiff::write(&named, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "Named", rustgis_map::RasterLayer::from_bytes(named, "named.tif", named_bytes)).unwrap(); let out = std::env::temp_dir().join(format!("rustgis-named-{}.tif", std::process::id())); let mark = s.notice_mark(); s.run("share.exportRaster", &json!({"layer": "Named", "path": out.display().to_string()})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.ends_with("the class names of band(s) 2 aren't written: a GeoTIFF keeps the first band's")), "{said:?}"); assert!(said.iter().all(|n| !n.contains("bands written")), "all three colour bands are written: {said:?}"); // A raster of two grey bands writes one, and says so. let mark = s.notice_mark(); s.run("share.exportRaster", &json!({"layer": "Paired", "path": out.display().to_string()})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.contains("; 1 of its 2 bands written (give \"band\" for another)")), "{said:?}"); // A colour raster of five bands writes its colour ones, whatever `band` is: not pointed to it. let mut five = rustgis_data::raster::Raster::new( "Five", 4, 4, vec![vec![1.0; 16]; 5], gt, rustgis_data::raster::Crs::LonLat, None, ColorHint::Rgb { alpha: true }, "Byte", ) .unwrap(); five.bands[4].name = "NIR".into(); let five_bytes = rustgis_data::raster::geotiff::write(&five, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "Five", rustgis_map::RasterLayer::from_bytes(five, "five.tif", five_bytes)).unwrap(); let mark = s.notice_mark(); s.run("share.exportRaster", &json!({"layer": "Five", "path": out.display().to_string()})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n.contains("; 4 of its 5 bands written (its colour bands)")), "{said:?}"); assert!(said.iter().all(|n| !n.contains("give \"band\"")), "{said:?}"); let _ = std::fs::remove_file(&out); assert_eq!( (ids.rounded.as_slice(), ids.value(0, 0, 0), ids.value(0, 1, 0)), ([32768u64].as_slice(), Some(16_777_216.0), Some(16_777_216.0)) ); // Read in place, a palette file is a raster layer whose overview comes with its colour table // and class names, as a window does: nothing is said to be left out. let (fresh, _) = serve_ranges(example_with(&Example { size: 512, tile: 256, classes: true, ..Example::default() })); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{fresh}/cover.tif"), "name": "In place"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().all(|n| !n.contains("drawn in place") && !n.contains("left out")), "{said:?}"); let held = raster(&s, "In place"); let ColorHint::Palette(table) = &held.color else { panic!("{:?}", held.color) }; assert_eq!((table[3], held.bands[0].class_name(3.0)), ([3, 252, 0, 255], Some("Forest Formation"))); let mark = s.notice_mark(); s.run("layer.addUrl", &json!({"url": format!("{base}/scene.tif"), "name": "Plain"})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("drawn in place")), "a grey file says nothing"); let cover = raster(&s, "Cover"); let ColorHint::Palette(table) = &cover.color else { panic!("{:?}", cover.color) }; assert_eq!(table[3], [3, 252, 0, 255]); assert_eq!((cover.bands[0].class_name(3.0), cover.bands[0].class_name(15.0)), (Some("Forest Formation"), Some("Pasture"))); // Saved, the project keeps the windows, not the addresses, and opens them again. let text = serde_json::to_string(&s.project).unwrap(); s.run("project.openText", &json!({"text": text})).unwrap(); let w = raster(&s, "Window"); assert_eq!((w.width, w.value(0, 10, 5)), (256, Some(15.0))); let w2 = raster(&s, "Two"); assert_eq!((w2.band_count(), w2.value(0, 10, 5)), (1, Some(15.0))); let cover = raster(&s, "Cover"); assert!(matches!(cover.color, ColorHint::Palette(_))); assert_eq!(cover.bands[0].class_name(15.0), Some("Pasture")); } /// Text and numeric dBase losses are counted only in rows the exported file holds. #[test] fn shapefile_value_losses_reach_export_notices() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; let dir = std::env::temp_dir().join(format!("rustgis-shp-value-notices-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut ds = Dataset::new( "Values", GeometryType::Point, vec![Field::new("description", FieldType::Text), Field::new("id", FieldType::Integer), Field::new("number", FieldType::Double)], ); ds.push( Some(Geometry::Point(rustgis_geom::Coord::new(1.0, 2.0))), vec![Attr::Text("é".repeat(150)), Attr::Int(9_007_199_254_740_993), Attr::Float(1.5e30)], ); ds.push(Some(Geometry::Point(rustgis_geom::Coord::new(3.0, 4.0))), vec![Attr::Text("fits".into()), Attr::Int(1), Attr::Float(1e-30)]); ds.push(None, vec![Attr::Text("x".repeat(300)), Attr::Int(i64::MAX), Attr::Float(1e-30)]); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds.clone()); s.project.add_table(ds); for command in ["share.exportLayer", "share.exportTable"] { for ext in ["shp", "zip"] { let path = dir.join(format!("{command}.{ext}")); let mark = s.notice_mark(); let params = if command == "share.exportLayer" { json!({"layer":id,"path":path}) } else { json!({"table":"Values","path":path}) }; s.run(command, ¶ms).unwrap(); let (back, _) = rustgis_data::io::read_path(&path).unwrap(); assert_eq!(back.features.len(), 2); let field = |name: &str| back.fields.iter().position(|f| f.name == name).unwrap(); assert_eq!(back.features[0].attrs[field("descriptio")], Attr::Text("é".repeat(127))); assert_eq!(back.features[0].attrs[field("id")], Attr::Null); assert_eq!(back.features[0].attrs[field("number")], Attr::Null); assert_eq!(back.features[1].attrs[field("number")], Attr::Null); let notices: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!( notices.iter().any(|n| n.contains("1 text value") && n.contains("254 bytes") && n.contains("description")), "{command}: {notices:?}" ); assert!( notices.iter().any(|n| n.contains("3 values") && n.contains("id") && n.contains("number") && n.contains("left empty")), "{command}: {notices:?}" ); assert!(notices.iter().any(|n| n.contains("1 features left out of the Shapefile")), "{command}: {notices:?}"); } } // A clean table gives neither loss notice; other formats keep the lossy attributes. let mut clean = s.project.tables[0].dataset.clone(); clean.name = "Fits".into(); clean.features.truncate(1); clean.features[0].attrs = vec![Attr::Text("fits".into()), Attr::Int(1), Attr::Float(1.25)]; s.project.add_table(clean); for (table, ext) in [("Fits", "shp"), ("Fits", "zip"), ("Values", "geojson")] { let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table":table,"path":dir.join(format!("{table}.{ext}"))})).unwrap(); assert!( !s.notices_since(mark).iter().any(|n| n.text.contains("254 bytes") || n.text.contains("left empty")), "{:?}", s.notices_since(mark) ); } std::fs::remove_dir_all(dir).unwrap(); } /// A GeoParquet export writes a value its column's type can't hold as null and says how many, /// in which fields; a clean export says nothing of it. #[test] fn geoparquet_export_says_which_values_it_wrote_as_null() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; let dir = std::env::temp_dir().join(format!("rustgis-parquet-unfit-notices-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let fields = vec![Field::new("id", FieldType::Integer), Field::new("open", FieldType::Boolean), Field::new("name", FieldType::Text)]; let mut ds = Dataset::new("Values", GeometryType::Point, fields); let at = |x: f64| Some(Geometry::Point(rustgis_geom::Coord::new(x, 2.0))); ds.push(at(1.0), vec![Attr::Float(1.5), Attr::Text("yes".into()), Attr::Text("a".into())]); ds.push(at(2.0), vec![Attr::Text("two".into()), Attr::Bool(true), Attr::Int(2)]); ds.push(at(3.0), vec![Attr::Int(3), Attr::Null, Attr::Null]); let mut clean = ds.clone(); clean.name = "Fits".into(); clean.features.truncate(1); clean.features[0].attrs = vec![Attr::Int(1), Attr::Bool(false), Attr::Text("a".into())]; let mut s = Session::new(Project::default()); let lossy_id = s.map_mut().add_dataset(ds.clone()); let clean_id = s.map_mut().add_dataset(clean.clone()); s.project.add_table(ds); s.project.add_table(clean); for command in ["share.exportLayer", "share.exportTable"] { for (layer, table, lossy) in [(lossy_id, "Values", true), (clean_id, "Fits", false)] { let path = dir.join(format!("{command}-{table}.parquet")); let mark = s.notice_mark(); let params = if command == "share.exportLayer" { json!({"layer":layer,"path":path}) } else { json!({"table":table,"path":path}) }; s.run(command, ¶ms).unwrap(); let notices: Vec<_> = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); let said = notices.iter().any(|n| n.contains("written as null")); if lossy { assert!( notices.iter().any(|n| n.contains("3 values") && n.contains("'id', 'open'") && n.contains("written as null")), "{command}: {notices:?}" ); let (back, _) = rustgis_data::io::read_path(&path).unwrap(); assert_eq!(back.features[0].attrs[0], Attr::Null); assert_eq!(back.features[1].attrs[2], Attr::Text("2".into())); } else { assert!(!said, "{command}: {notices:?}"); } } } std::fs::remove_dir_all(dir).unwrap(); } /// Exporting to a Shapefile says which field names it changed to fit dBase's 10 bytes, as the /// file has them, from a layer read whole or in place; names that fit, and other formats, give no /// such notice. #[test] fn shapefile_exports_say_which_field_names_they_changed() { let dir = std::env::temp_dir().join(format!("rustgis-engine-shp-names-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("sites.geojson"); std::fs::write( &src, r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"population_2020":1,"Population_2021":2,"id":3},"geometry":{"type":"Point","coordinates":[1,2]}}]}"#, ) .unwrap(); isolate_plugins(); let mut s = Session::new(Project::default()); let changed = |s: &Session, mark| -> Vec { s.notices_since(mark).iter().filter(|n| n.text.contains("changed to fit a Shapefile")).map(|n| n.text.clone()).collect() }; for stream in [false, true] { let id = s.run("layer.addData", &json!({"path": src, "stream": stream})).unwrap()["layer"].as_u64().unwrap(); for ext in ["shp", "zip"] { let out = dir.join(format!("out-{stream}.{ext}")).display().to_string(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": out})).unwrap(); assert_eq!( changed(&s, mark), [format!( "{out}: 2 field names were changed to fit a Shapefile (10 bytes at most, unique ignoring case): \ 'Population_2021' → 'Population', 'population_2020' → 'populati_1'" )], "stream {stream}, .{ext}" ); // The names the notice gives are the file's. let back = s.run("layer.addData", &json!({"path": out})).unwrap()["layer"].as_u64().unwrap() as u32; let names: Vec<&str> = s.map().layer(back).unwrap().dataset.fields.iter().map(|f| f.name.as_str()).collect(); // The reader orders the fields by name, capitals first. assert_eq!(names, ["Population", "id", "populati_1"], "stream {stream}, .{ext}"); } // Other formats keep the names and say nothing of them. let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": dir.join(format!("out-{stream}.geojson"))})).unwrap(); assert!(changed(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); } // Names that fit: no notice. let mut s = world(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Countries", "path": dir.join("countries.shp")})).unwrap(); assert!(changed(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); // A standalone table can hold geometry (a plugin's output table is read from GeoJSON whole, // and a project file keeps a table's geometry), so `share.exportTable` can write a Shapefile // too, and says the same of its names. use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType}; use rustgis_geom::Coord; let table = |name: &str, field: &str| { let mut ds = Dataset::new(name, GeometryType::Point, vec![Field::new(field, FieldType::Integer)]); ds.push(Some(Geometry::Point(Coord::new(1.0, 2.0))), vec![rustgis_data::Value::Int(1)]); ds }; s.project.add_table(table("Sites", "population_2020")); for ext in ["shp", "zip"] { let out = dir.join(format!("sites.{ext}")).display().to_string(); let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table": "Sites", "path": out})).unwrap(); assert_eq!( changed(&s, mark), [format!( "{out}: 1 field name was changed to fit a Shapefile (10 bytes at most, unique ignoring case): \ 'population_2020' → 'population'" )], ".{ext}" ); let back = s.run("layer.addData", &json!({"path": out})).unwrap()["layer"].as_u64().unwrap() as u32; let names: Vec<&str> = s.map().layer(back).unwrap().dataset.fields.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["population"], ".{ext}"); } // To another format, or with names that fit: no notice. s.project.add_table(table("Fits", "population")); for (table, ext) in [("Sites", "geojson"), ("Fits", "shp")] { let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table": table, "path": dir.join(format!("{table}.{ext}"))})).unwrap(); assert!(changed(&s, mark).is_empty(), "{table}.{ext}: {:?}", s.notices_since(mark)); } // A table without geometry is never a Shapefile: an error, and nothing said of its names. s.run("gp.run", &json!({"tool": "summaryStatistics", "params": {"input": "Countries", "field": "pop_est"}})).unwrap(); let stats = s.project.tables.last().unwrap().name.clone(); let mark = s.notice_mark(); assert!(s.run("share.exportTable", &json!({"table": stats, "path": dir.join("stats.shp")})).is_err()); assert!(changed(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); std::fs::remove_dir_all(dir).unwrap(); } /// Exporting to Web Mercator says how many vertices beyond 85.05° it writes at 85.05°, where Web /// Mercator ends: the World Atlas's countries reach the South Pole, and the file's stop at 85.05° /// south. In longitude/latitude nothing is said of it. #[test] fn web_mercator_exports_say_what_they_write_at_85_degrees() { let dir = std::env::temp_dir().join(format!("rustgis-engine-merc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut s = world(); let said = |s: &Session, mark| -> Vec { s.notices_since(mark).iter().filter(|n| n.text.contains("where Web Mercator ends")).map(|n| n.text.clone()).collect() }; let south = |s: &Session, id: u32| s.map().layer(id).unwrap().dataset.extent().min_y; let mut beyond = 0; for g in s.map().layers.iter().filter(|l| l.name == "Countries").flat_map(|l| l.dataset.features.iter()).filter_map(|f| f.geometry.as_ref()) { g.for_each_coord(|c| beyond += usize::from(c.y.abs() > rustgis_geom::MAX_LAT)); } assert!(beyond > 0); let out = dir.join("countries.shp").display().to_string(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Countries", "path": out, "crs": "EPSG:3857"})).unwrap(); assert_eq!( said(&s, mark), [format!("{out}: {beyond} vertices of Countries are beyond 85.05° north or south, and written at 85.05°, where Web Mercator ends")], "{:?}", s.notices_since(mark) ); let back = s.run("layer.addData", &json!({"path": out})).unwrap()["layer"].as_u64().unwrap() as u32; assert!((south(&s, back) + rustgis_geom::MAX_LAT).abs() < 1e-6, "{}", south(&s, back)); // In longitude/latitude, the layer's southern edge is kept as it is, and nothing is said of it. let source_south = s.map().layers.iter().find(|l| l.name == "Countries").unwrap().dataset.extent().min_y; assert!(source_south < -rustgis_geom::MAX_LAT, "{source_south}"); let mark = s.notice_mark(); let lonlat = dir.join("countries-4326.shp"); s.run("share.exportLayer", &json!({"layer": "Countries", "path": lonlat, "crs": "EPSG:4326"})).unwrap(); assert!(said(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); let back = s.run("layer.addData", &json!({"path": lonlat})).unwrap()["layer"].as_u64().unwrap() as u32; assert!((south(&s, back) - source_south).abs() < 1e-6, "{} for {source_south}", south(&s, back)); // A raster layer reaching the poles writes no vertices: nothing is said of 85.05°. let world_tif = { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: -180.0, origin_y: 90.0, pixel_width: 45.0, pixel_height: -45.0 }; let bands = (0..3).map(|b| (0..32).map(|i| ((i * 7 + b * 40) % 256) as f32).collect()).collect(); let r = Raster::new("globe", 8, 4, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt8").unwrap(); base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()) }; let globe = s.run("layer.addDataBytes", &json!({"fileName": "globe.tif", "data": world_tif})).unwrap()["layer"].as_u64().unwrap(); assert!(s.map().layer(globe as u32).unwrap().is_raster()); let mark = s.notice_mark(); let out = dir.join("globe.gpkg").display().to_string(); s.run("share.exportLayer", &json!({"layer": globe, "path": out, "crs": "EPSG:3857"})).unwrap(); assert!(said(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); // Streamed, the countries are counted as they're written a chunk at a time (to a GeoPackage), // whatever extent the source declares, and read whole (for a Shapefile), from their features. let gpkg = dir.join("countries.gpkg").display().to_string(); s.run("share.exportLayer", &json!({"layer": "Countries", "path": gpkg, "crs": "EPSG:4326"})).unwrap(); s.run("layer.addData", &json!({"path": gpkg, "stream": true, "name": "Streamed"})).unwrap(); let notice = format!("{beyond} vertices of Streamed are beyond 85.05° north or south, and written at 85.05°, where Web Mercator ends"); for file in ["streamed.gpkg", "streamed.shp"] { let out = dir.join(file).display().to_string(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": "Streamed", "path": out, "crs": "EPSG:3857"})).unwrap(); assert_eq!(said(&s, mark), [format!("{out}: {notice}")], "{:?}", s.notices_since(mark)); } // The streamed layer holds its GeoPackage open (Windows won't delete it till it's closed). drop(s); std::fs::remove_dir_all(dir).unwrap(); } /// Exporting to a Shapefile says how many vertices with a missing or infinite coordinate it left /// out, from a layer and from a standalone table; clean geometry gives no such notice (#1687). #[test] fn shapefile_exports_say_which_vertices_they_leave_out() { use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::Coord; let dir = std::env::temp_dir().join(format!("rustgis-engine-shp-vertices-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); isolate_plugins(); let mut s = Session::new(Project::default()); let said = |s: &Session, mark| -> Vec { s.notices_since(mark).iter().filter(|n| n.text.contains("missing or infinite")).map(|n| n.text.clone()).collect() }; let stations = |name: &str, x: f64| { let mut ds = Dataset::new(name, GeometryType::Point, vec![]); let members = vec![Coord::new(0.0, 0.0), Coord::new(x, 1.0), Coord::new(2.0, 2.0), Coord::new(3.0, 3.0)]; ds.push(Some(Geometry::MultiPoint(members)), vec![]); ds }; let id = s.map_mut().add_layer(rustgis_map::Layer::new(0, stations("mp", f64::NAN))); for ext in ["shp", "zip"] { let out = dir.join(format!("mp.{ext}")).display().to_string(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": id, "path": out})).unwrap(); assert_eq!( said(&s, mark), [format!("{out}: 1 vertex with a missing or infinite coordinate was left out of the Shapefile")], ".{ext}" ); } s.project.add_table(stations("Stations", f64::INFINITY)); let out = dir.join("stations.shp").display().to_string(); let mark = s.notice_mark(); s.run("share.exportTable", &json!({"table": "Stations", "path": out})).unwrap(); assert_eq!(said(&s, mark), [format!("{out}: 1 vertex with a missing or infinite coordinate was left out of the Shapefile")]); // Clean geometry: no notice. let clean = s.map_mut().add_layer(rustgis_map::Layer::new(0, stations("clean", 1.0))); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": clean, "path": dir.join("clean.shp")})).unwrap(); assert!(said(&s, mark).is_empty(), "{:?}", s.notices_since(mark)); // Projected, an infinite latitude is written (Web Mercator puts it at 85.05°, which the 85.05° // notice says), and isn't said to be left out; in longitude/latitude it's left out, and said. let mut polar = Dataset::new("polar", GeometryType::Point, vec![]); polar.push(Some(Geometry::MultiPoint(vec![Coord::new(0.0, 0.0), Coord::new(1.0, f64::INFINITY), Coord::new(2.0, 2.0)])), vec![]); let polar = s.map_mut().add_layer(rustgis_map::Layer::new(0, polar)); for (crs, file, notes) in [("EPSG:3857", "polar-3857.shp", 0), ("EPSG:4326", "polar-4326.shp", 1)] { let out = dir.join(file).display().to_string(); let mark = s.notice_mark(); s.run("share.exportLayer", &json!({"layer": polar, "path": out, "crs": crs})).unwrap(); assert_eq!(said(&s, mark).len(), notes, "{crs}: {:?}", s.notices_since(mark)); } std::fs::remove_dir_all(dir).unwrap(); } #[test] fn a_cancelled_tool_leaves_the_map_as_it_was_and_says_so_in_the_history() { let mut s = world(); let layers = s.map().layers.len(); let before = serde_json::to_value(&s.map().layers).unwrap(); let control = rustgis_analysis::control::Control::new(); control.cancel(); let params = json!({"input": "Cities", "field": "pop_max", "pixelSize": 5.0}); let e = crate::cmd::gp::run_tool_with(&mut s, "idwInterpolation", ¶ms, &control).err().unwrap_or_default(); assert!(e.starts_with("IDW Interpolation was cancelled"), "{e}"); assert_eq!(s.map().layers.len(), layers, "nothing was added"); assert_eq!(serde_json::to_value(&s.map().layers).unwrap(), before, "no layer changed"); let last = s.project.gp_history.last().map(|r| (r.ok, r.messages.clone())); assert_eq!(last, Some((false, vec![e]))); // Nothing is read for it either: a missing file among its inputs isn't reached. let missing = std::env::temp_dir().join(format!("rustgis-no-such-{}.geojson", std::process::id())); let merge = json!({"inputs": [missing.display().to_string(), "Cities"]}); let e = crate::cmd::gp::run_tool_with(&mut s, "merge", &merge, &control).err().unwrap_or_default(); assert_eq!(e, "Merge was cancelled before it started"); // Not cancelled, the missing file is read, and that is what fails. let e = crate::cmd::gp::run_tool(&mut s, "merge", &merge).err().unwrap_or_default(); assert!(e.starts_with(&missing.display().to_string()), "{e}"); // The same run, not cancelled, adds its raster. crate::cmd::gp::run_tool(&mut s, "idwInterpolation", ¶ms).unwrap_or_else(|e| panic!("{e}")); assert_eq!(s.map().layers.len(), layers + 1); } #[cfg(not(target_arch = "wasm32"))] #[test] fn built_in_tools_run_off_the_session() { use crate::cmd::gp::{tool_abandon, tool_complete, tool_start, tool_waiting}; use rustgis_analysis::control::Control; fn sends(_: &T) {} let mut s = world(); // Started: what it reads is taken now; it runs elsewhere while the project goes on. let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); assert_eq!(run.tool, "buffer"); sends(&run); let id = run.id; assert!(tool_waiting(&s, id)); let made = std::thread::spawn(move || run.run(&Control::new())); s.run("layer.remove", &json!({"layer": "Cities"})).unwrap(); let done = made.join().unwrap(); tool_complete(&mut s, id, done).unwrap(); // Applied as gp.run applies a run: its layer, its history, undo. let before = s.map().layers.len(); s.run("gp.finish", &json!({"id": id})).unwrap(); assert_eq!(s.map().layers.len(), before + 1); let h = s.project.gp_history.last().unwrap(); assert!(h.ok && h.tool == "Buffer" && h.params["input"] == "Cities", "{h:?}"); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(s.map().layers.len(), before); // Cancelled before it starts (a cancel while it runs is the analysis crate's test): it fails, // saying so, and adds nothing (a failed command leaves the project as it was, as a failed // gp.run does). let mut s = world(); let layers = s.map().layers.len(); let run = tool_start(&mut s, "idwInterpolation", &json!({"input": "Cities", "field": "pop_max", "pixelSize": 5.0})).unwrap(); let control = Control::new(); control.cancel(); let id = run.id; let done = run.run(&control); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert_eq!(e, "IDW Interpolation was cancelled before it started"); assert_eq!(s.map().layers.len(), layers); // Inputs it can't read fail its run, in the history, rather than its start. let missing = std::env::temp_dir().join(format!("rustgis-no-such-bg-{}.geojson", std::process::id())); let run = tool_start(&mut s, "merge", &json!({"inputs": [missing.display().to_string(), "Cities"]})).unwrap(); let id = run.id; let done = run.run(&Control::new()); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.starts_with(&missing.display().to_string()), "{e}"); // Parameters are checked when it starts; plugin tools start their own way; a run completes once. assert!(tool_start(&mut s, "buffer", &json!({})).err().unwrap().contains("required")); assert!(tool_start(&mut s, "plugin:none/x", &json!({})).err().unwrap().contains("no tool")); let again = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); let id = again.id; tool_complete(&mut s, id, again.run(&Control::new())).unwrap(); let late = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); let done = late.run(&Control::new()); assert!(tool_complete(&mut s, id, done).unwrap_err().contains("dropped"), "completed once"); s.run("gp.finish", &json!({"id": id})).unwrap(); // A run whose map is no longer the active one adds nothing to the one that is, and says so. s.run("map.new", &json!({"name": "Second"})).unwrap(); s.project.active_map = 0; let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.project.active_map = 1; let layers = s.map().layers.len(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.contains("no longer the active one"), "{e}"); assert_eq!(s.map().layers.len(), layers); // A standalone table belongs to the project, so its owned result can land while another map is in view. s.project.active_map = 0; let run = tool_start(&mut s, "summaryStatistics", &json!({"input":"Cities","field":"pop_max"})).unwrap(); s.project.active_map = 1; let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let made = s.run("gp.finish", &json!({"id":id})).unwrap(); assert_eq!(made["table"], "Cities_Statistics"); assert!(s.project.tables.iter().any(|t| t.dataset.name == "Cities_Statistics")); assert_eq!(s.map().layers.len(), layers); // A tool that rewrites its layer applies when the layer is as it read it, and is refused when // it was edited meanwhile, even only in its fields (the rows, however many, aren't compared). let mut s = world(); let run = tool_start(&mut s, "calculateGeometry", &json!({"input": "Countries"})).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); // Renamed meanwhile, the layer it read is updated, under its new name. let run = tool_start(&mut s, "calculateGeometry", &json!({"input": "Countries"})).unwrap(); s.run("layer.rename", &json!({"layer": "Countries", "name": "Nations"})).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); let nations = s.map().layer_by_name("Nations").unwrap(); assert_eq!(nations.dataset.name, "Nations"); s.run("layer.rename", &json!({"layer": "Nations", "name": "Countries"})).unwrap(); let run = tool_start(&mut s, "calculateGeometry", &json!({"input": "Countries"})).unwrap(); let countries = s.map_mut().layers.iter_mut().find(|l| l.name == "Countries").unwrap(); countries.dataset.fields[0].name = "renamed".into(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.contains("changed while"), "{e}"); // Opening another project drops its runs; one whose thread couldn't start is forgotten. let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.run("project.new", &Value::Null).unwrap(); assert!(!tool_waiting(&s, run.id)); let mut s = world(); let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); tool_abandon(&mut s, run.id); assert!(!tool_waiting(&s, run.id)); } /// A background selection on a streamed layer renamed while it ran selects the features it chose /// where they are in the layer's window; a run whose map moved (one before it was deleted) still /// lands on it. #[cfg(not(target_arch = "wasm32"))] #[test] fn background_runs_follow_renamed_streamed_layers_and_moved_maps() { use crate::cmd::gp::{tool_complete, tool_start}; use rustgis_analysis::control::Control; let path = grid_fgb("bgrenamed", 50); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); // Only a corner is in view: the window holds part of the source, in its own order. let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); assert!(s.map().layers[0].dataset.features.len() < 2500, "only part of the source is in the window"); let run = tool_start(&mut s, "selectByAttributes", &json!({"input": "bgrenamed", "where": "x > 0 AND x < 20"})).unwrap(); s.run("layer.rename", &json!({"layer": "bgrenamed", "name": "Renamed"})).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); let l = s.map().layer_by_name("Renamed").unwrap(); let x = |f: &rustgis_data::Feature| f.attrs.get(1).and_then(|v| v.as_f64()).unwrap_or(f64::NAN); let mut selected: Vec = l.selection.iter().map(|&r| l.dataset.features[r].id).collect(); let mut expected: Vec = l.dataset.features.iter().filter(|f| x(f) > 0.0 && x(f) < 20.0).map(|f| f.id).collect(); selected.sort_unstable(); expected.sort_unstable(); assert!(!expected.is_empty() && selected == expected, "{selected:?} vs {expected:?}"); // What it selected outside the window isn't selected, and that is said. let said = &s.project.gp_history.last().unwrap().messages; assert!(said.iter().any(|m| m.contains("outside the part of it read for the view")), "{said:?}"); let _ = std::fs::remove_file(path); // A map deleted before the run's own map moves it up: the result still lands there. let mut s = world(); s.project.maps.insert(0, rustgis_map::Map::new("Before")); s.project.active_map = 1; let layers = s.map().layers.len(); let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.run("map.remove", &json!({"name": "Before"})).unwrap(); assert_eq!(s.project.active_map, 0); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); // Its map deleted and another given its name, holding a layer of the same id from another // source: that isn't the layer it read, and its selection doesn't land there. let path = grid_fgb("bgother", 5); let mut s = world(); s.run("map.new", &json!({"name": "Ran"})).unwrap(); s.project.active_map = s.project.maps.iter().position(|m| m.name == "Ran").unwrap(); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true, "name": "Points"})).unwrap(); let read_id = s.map().layer_by_name("Points").unwrap().id; let run = tool_start(&mut s, "selectByAttributes", &json!({"input": "Points", "where": "i >= 0"})).unwrap(); s.run("map.remove", &json!({"name": "Ran"})).unwrap(); s.run("map.new", &json!({"name": "Ran"})).unwrap(); s.project.active_map = s.project.maps.iter().position(|m| m.name == "Ran").unwrap(); let other = grid_fgb("bgother2", 5); s.run("layer.addData", &json!({"path": other.display().to_string(), "stream": true, "name": "Points"})).unwrap(); assert_eq!(s.map().layer_by_name("Points").unwrap().id, read_id, "the same id, on a map of the same name"); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.contains("was deleted or is no longer the active one"), "{e}"); assert!(s.map().layer_by_name("Points").unwrap().selection.is_empty()); let _ = (std::fs::remove_file(path), std::fs::remove_file(other)); } /// A background run's result lands on its map renamed while it ran; its selection of a streamed /// layer whose window was read again meanwhile selects the same features, saying how many left /// it; and its update of a streamed layer loaded whole and edited meanwhile is refused. #[cfg(not(target_arch = "wasm32"))] #[test] fn background_runs_follow_a_renamed_map_a_moved_window_and_a_loaded_layer() { use crate::cmd::gp::{tool_complete, tool_start}; use rustgis_analysis::control::Control; let mut s = world(); let layers = s.map().layers.len(); let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.run("map.rename", &json!({"name": "Renamed while it ran"})).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); // Renamed and the rename undone: the same map. let layers = s.map().layers.len(); let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.run("map.rename", &json!({"name": "Renamed again"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); assert_eq!(s.map().layers.len(), layers + 1); // Deleted, and another map made with its name: the new layer doesn't land there. s.run("map.new", &json!({"name": "Other"})).unwrap(); let ran = s.project.maps[0].name.clone(); s.project.active_map = 0; let run = tool_start(&mut s, "buffer", &json!({"input": "Cities", "distance": 50})).unwrap(); s.run("map.remove", &json!({"name": ran})).unwrap(); s.run("map.new", &json!({"name": ran})).unwrap(); s.project.active_map = s.project.maps.iter().position(|m| m.name == ran).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.contains("was deleted or is no longer the active one"), "{e}"); assert!(s.map().layers.is_empty()); // A selection in the window (so the tool reads the window), then the view moves. let path = grid_fgb("bgwindow", 50); let mut s = Session::new(Project::default()); s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(s.map_mut(), &v).unwrap(); let ids = |s: &Session| -> Vec { s.map().layers[0].dataset.features.iter().map(|f| f.id).collect() }; let x = |f: &rustgis_data::Feature| f.attrs.get(1).and_then(|v| v.as_f64()).unwrap_or(f64::NAN); let picked: std::collections::HashSet = s.map().layers[0].dataset.features.iter().filter(|f| x(f) > 0.0 && x(f) < 20.0).map(|f| f.id).collect(); let before = ids(&s); let n = before.len(); s.map_mut().layers[0].selection = (0..n).collect(); let run = tool_start(&mut s, "selectByAttributes", &json!({"input": "bgwindow", "where": "x > 0 AND x < 20"})).unwrap(); // The view moved: the window, read again, holds part of what it did, in another order. let l = &mut s.map_mut().layers[0]; l.dataset.features = l.dataset.features.iter().rev().filter(|f| f.id % 3 != 0).cloned().collect(); l.selection.clear(); let after = ids(&s); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); s.run("gp.finish", &json!({"id": id})).unwrap(); let l = &s.map().layers[0]; let mut selected: Vec = l.selection.iter().map(|&r| l.dataset.features[r].id).collect(); let mut expected: Vec = after.iter().copied().filter(|i| picked.contains(i)).collect(); selected.sort_unstable(); expected.sort_unstable(); assert!(!expected.is_empty() && expected.len() < picked.len(), "{} of {}", expected.len(), picked.len()); assert_eq!(selected, expected); let said = &s.project.gp_history.last().unwrap().messages; assert!(said.iter().any(|m| m.contains("no longer there")), "{said:?}"); // Streamed and read whole, then loaded whole while the tool ran, and one of the features it // selects deleted: its selection, rows of the whole source, lands on the others, and says so. s.map_mut().layers[0].selection.clear(); let mut t = Session::new(Project::default()); t.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); let v = view_of(&mut t, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); crate::render::render_png(t.map_mut(), &v).unwrap(); let run = tool_start(&mut t, "selectByAttributes", &json!({"input": "bgwindow", "where": "i < 100"})).unwrap(); t.run("layer.loadAll", &json!({"layer": t.map().layers[0].id})).unwrap(); // One of the features it selects is deleted meanwhile. let i = |f: &rustgis_data::Feature| f.attrs.first().and_then(|v| v.as_f64()).unwrap_or(f64::NAN); let first = t.map().layers[0].dataset.features.iter().position(|f| i(f) == 0.0).unwrap(); t.map_mut().layers[0].selection = vec![first]; t.run("edit.enable", &json!({"on": true})).unwrap(); t.run("edit.delete", &Value::Null).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut t, id, done).unwrap(); t.run("gp.finish", &json!({"id": id})).unwrap(); let l = &t.map().layers[0]; assert!(!l.is_streamed()); assert_eq!(l.selection.len(), 99); assert!(l.selection.iter().all(|&r| i(&l.dataset.features[r]) < 100.0 && i(&l.dataset.features[r]) != 0.0)); let said = &t.project.gp_history.last().unwrap().messages; assert!(said.iter().any(|m| m.contains("1 of the features selected in bgwindow were deleted while it ran")), "{said:?}"); // Streamed and read whole, then loaded whole and edited while the tool ran: its update // would discard the edit. let run = tool_start(&mut s, "calculateGeometry", &json!({"input": "bgwindow"})).unwrap(); s.run("layer.loadAll", &json!({"layer": s.map().layers[0].id})).unwrap(); s.run("edit.enable", &json!({"on": true})).unwrap(); s.run("edit.setValue", &json!({"layer": "bgwindow", "row": 0, "field": "i", "value": -1})).unwrap(); let (id, done) = (run.id, run.run(&Control::new())); tool_complete(&mut s, id, done).unwrap(); let e = s.run("gp.finish", &json!({"id": id})).unwrap_err(); assert!(e.contains("loaded whole or replaced while"), "{e}"); assert!(s.map().layers[0].dataset.features.iter().any(|f| f.attrs.first().and_then(|v| v.as_f64()) == Some(-1.0))); let _ = std::fs::remove_file(path); } #[test] fn validate_topology_says_which_checks_are_incomplete() { // A line 11 m from the North Pole winding round it some 19,000 times is measured coarsely, so // Must Not Have Dangles is incomplete and the validation says so; a plain line, nothing (#1033). use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; let c = Coord::new; for (to, warned) in [(7_000_000.0, true), (1.0, false)] { let mut s = Session::new(Project::default()); let mut paths = Dataset::new("Paths", GeometryType::Polyline, vec![]); paths.push(Some(Geometry::Line(vec![vec![c(0.0, 89.9999), c(to, 89.9999)]])), vec![]); s.map_mut().add_dataset(paths); s.run("topology.rules", &json!({"add": {"rule": "mustNotHaveDangles", "layer": "Paths"}})).unwrap(); let mark = s.notice_mark(); let v = s.run("topology.validate", &Value::Null).unwrap(); assert_eq!(v["incomplete"].as_array().map(Vec::len), Some(usize::from(warned)), "{v}"); let said = s .notices_since(mark) .iter() .any(|n| n.text.contains("topology rule(s) weren't checked completely") && n.text.contains("Must Not Have Dangles")); assert_eq!(said, warned, "{:?}", s.notices_since(mark)); } } #[test] fn must_be_covered_by_says_which_polygons_it_left_out_at_a_pole() { // Around the South Pole, a cap from 20°E round to 10°E and the 10° between written a // billion degrees on: the check at the pole can't use that one, reports the point there and // warns why; with a whole cap it says nothing of the kind (#1033). use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; let c = Coord::new; let ring = |w: f64, e: f64| Geometry::Polygon(vec![vec![vec![c(w, -85.0), c(e, -85.0), c(e, -90.0), c(w, -90.0), c(w, -85.0)]]]); for (caps, warned) in [(vec![ring(20.0, 370.0), ring(1e9 + 90.0, 1e9 + 100.0)], true), (vec![ring(-180.0, 180.0)], false)] { let mut s = Session::new(Project::default()); let mut by = Dataset::new("Caps", GeometryType::Polygon, vec![]); for g in caps { by.push(Some(g), vec![]); } let mut pts = Dataset::new("Pole", GeometryType::Point, vec![]); pts.push(Some(Geometry::Point(c(0.0, -90.0))), vec![]); s.map_mut().add_dataset(by); s.map_mut().add_dataset(pts); s.run("topology.rules", &json!({"add": {"rule": "mustBeCoveredBy", "layer": "Pole", "by": "Caps"}})).unwrap(); let mark = s.notice_mark(); let v = s.run("topology.validate", &Value::Null).unwrap(); assert_eq!(v["errors"], u64::from(warned)); assert_eq!(v["incomplete"].as_array().map(Vec::len), Some(usize::from(warned))); let said = s.notices_since(mark).iter().any(|n| { n.text.contains("left out of the check at a pole") && n.text.contains("Must Be Covered By on 'Pole': 1 feature(s) of 'Caps'") }); assert_eq!(said, warned, "{:?}", s.notices_since(mark)); } } #[test] fn a_pdf_says_which_frames_and_pages_it_draws_as_pictures() { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); crate::cmd::layout::pictured_notices(&mut s, &[], &[]); assert!(s.notices_since(mark).is_empty(), "nothing drawn as a picture, nothing said"); // "Main" pictured on two pages is one element; another element named "Main" is a second. let frames = [(3, "Inset".to_string()), (1, "Main".to_string()), (1, "Main".to_string()), (7, "Main".to_string())]; crate::cmd::layout::pictured_notices(&mut s, &frames, &[2, 5]); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(said.len(), 2, "one notice for each kind: {said:?}"); // Each frame once, however many pages it was pictured on. assert!( said[0].starts_with("The export draws 3 layout elements as pictures") && said[0].ends_with("(Main, Inset, Main)"), "{}", said[0] ); assert!(said[1].starts_with("The export draws 2 pages as pictures") && said[1].ends_with("(page 2, 5)"), "{}", said[1]); let mark = s.notice_mark(); crate::cmd::layout::pictured_notices(&mut s, &[], &(1..=25).collect::>()); assert!( s.notices_since(mark)[0].text.ends_with("(page 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 and 5 more)") ); } /// A layout's PDF georeferences its map frames (#1191): each as an ISO 32000 viewport with its /// corners and its map's coordinate system, so that the frame's corners, taken back to the map, /// make the frame's rectangle. A tilted frame is left out, and said. #[test] fn a_layout_pdf_georeferences_its_map_frames() { let mut s = world(); s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let frame = s.project.layouts[0].elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap().clone(); s.run("layout.update", &json!({"element": frame.id, "center": [-45.0, -20.0], "scale": 2_000_000.0})).unwrap(); let path = std::env::temp_dir().join(format!("rustgis-geopdf-{}.pdf", std::process::id())); let export = |s: &mut Session| { let mark = s.notice_mark(); s.run("layout.export", &json!({"path": path, "dpi": 72})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); (String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(), said) }; let (pdf, said) = export(&mut s); assert!(pdf.starts_with("%PDF-1.7") && pdf.contains("/ExtensionLevel 3"), "{}", &pdf[..20]); assert!(pdf.contains("/VP [<< /Type /Viewport") && pdf.contains("/Subtype /GEO")); assert!(pdf.contains("/GCS << /Type /PROJCS /WKT (PROJCS[") && pdf.contains("/EPSG 31983 >>"), "{pdf}"); assert!(!said.iter().any(|t| t.contains("georeference")), "{said:?}"); // The corners, back on the map, are the frame's rectangle: its top and bottom level, its // sides upright, and as wide as the frame at its scale. let gpts = pdf.split("/GPTS [").nth(1).and_then(|r| r.split(']').next()).unwrap(); let n: Vec = gpts.split_whitespace().map(|v| v.parse().unwrap()).collect(); assert_eq!(n.len(), 8, "{gpts}"); let m: Vec = n.chunks(2).map(|c| s.map().to_map(rustgis_geom::Coord::new(c[1], c[0])).unwrap()).collect(); let (tl, tr, br, bl) = (m[0], m[1], m[2], m[3]); assert!((tl.y - tr.y).abs() < 0.01 && (bl.y - br.y).abs() < 0.01, "{m:?}"); assert!((tl.x - bl.x).abs() < 0.01 && (tr.x - br.x).abs() < 0.01, "{m:?}"); let wide = frame.w / 1000.0 * 2_000_000.0; assert!(((tr.x - tl.x) / wide - 1.0).abs() < 1e-3, "{} m for {wide} m", tr.x - tl.x); assert!(tl.y > bl.y, "north up"); // Tilted, it shows no flat map to measure on: left out, and said. s.run("layout.update", &json!({"element": frame.id, "pitch": 30})).unwrap(); let (pdf, said) = export(&mut s); assert!(pdf.starts_with("%PDF-1.4") && !pdf.contains("/VP [")); assert!(said.iter().any(|t| t == &format!("The PDF doesn't georeference 1 map frame ({}: it is tilted)", frame.name)), "{said:?}"); // A world projection RustGIS computes itself (Winkel Tripel) is written as WKT too. s.run("layout.update", &json!({"element": frame.id, "pitch": 0})).unwrap(); s.run("map.projection", &json!({"type": "winkelTripel"})).unwrap(); let (pdf, said) = export(&mut s); assert!(pdf.contains("/GCS << /Type /PROJCS /WKT (PROJCS[") && pdf.contains("PROJECTION[\"Winkel_Tripel\"]"), "{pdf}"); assert!(!said.iter().any(|t| t.contains("georeference")), "{said:?}"); // A projection with no WKT form (the GOES-16 fixed grid): at a large scale its corners in // longitude/latitude put every place within half a millimetre, so it is georeferenced by // them, and said; at a small one they wouldn't, so it is left out: no viewports. s.run("map.projection", &json!({"type": "ESRI:102498"})).unwrap(); s.run("layout.update", &json!({"element": frame.id, "center": [-60.0, -10.0], "scale": 50_000.0})).unwrap(); let (pdf, said) = export(&mut s); assert!(pdf.contains("/GCS << /Type /GEOGCS /WKT (GEOGCS[") && pdf.contains("/EPSG 4326 >>"), "{said:?}"); let corners = said.iter().find(|t| t.starts_with("The PDF georeferences 1 map frame by its corners")); assert!(corners.is_some_and(|t| t.ends_with(&format!("({})", frame.name))), "{said:?}"); let level = s.notices_since(0).iter().rev().find(|n| n.text.starts_with("The PDF georeferences")).unwrap().severity; assert_eq!(level, rustgis_data::diagnostics::Severity::Info, "information, not a warning"); s.run("layout.update", &json!({"element": frame.id, "scale": 10_000_000.0})).unwrap(); let (pdf, said) = export(&mut s); assert!(!pdf.contains("/Subtype /GEO") && !pdf.contains("/VP ["), "nothing to measure in, no viewports"); assert!(said.iter().any(|t| t.contains("its corners alone would put places more than 0.5 mm off")), "{said:?}"); // Turned off, nothing is georeferenced and nothing said. s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); s.run("layout.update", &json!({"element": frame.id, "center": [-45.0, -20.0], "scale": 2_000_000.0})).unwrap(); let mark = s.notice_mark(); s.run("layout.export", &json!({"path": path, "dpi": 72, "georeference": false})).unwrap(); let plain = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); assert!(plain.starts_with("%PDF-1.4") && !plain.contains("/VP [")); assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("georeference"))); // On SAD69 (shifted from WGS 84) the corners are on SAD69, tens of metres from WGS 84's, and // moved as PROJ moves them: EPSG:4326 to EPSG:4618 takes this corner (-46.888358 E, // -18.436173 N) 0.000447° east and 0.000468° north (QGIS 3.32.1's gdaltransform). s.run("map.projection", &json!({"type": "EPSG:29193"})).unwrap(); let (pdf, _) = export(&mut s); let gpts = pdf.split("/GPTS [").nth(1).and_then(|r| r.split(']').next()).unwrap(); let n: Vec = gpts.split_whitespace().map(|v| v.parse().unwrap()).collect(); let frame_now = s.project.layouts[0].element(frame.id).unwrap().clone(); let rustgis_map::layout::ElementKind::MapFrame { center, scale, .. } = &frame_now.kind else { panic!() }; let cam = rustgis_map::layout::frame_camera(s.map(), *center, *scale, 0.0, 0.0); let wgs84 = s .map() .to_lonlat(cam.to_map(0.0, 0.0, frame.w * rustgis_map::layout::PT_PER_MM, frame.h * rustgis_map::layout::PT_PER_MM)) .unwrap(); let moved = ((n[1] - wgs84.x) * 104_600.0).hypot((n[0] - wgs84.y) * 110_600.0); assert!(pdf.contains("/EPSG 29193 >>") && moved > 10.0 && moved < 200.0, "{moved} m"); let off = ((n[1] - wgs84.x - 0.000_447) * 104_600.0).hypot((n[0] - wgs84.y - 0.000_468) * 110_600.0); assert!(off < 10.0, "{off} m from PROJ's shift"); // Across 180° in Web Mercator, a corner comes back on the other side of the world: left out. s.run("map.projection", &json!({"type": "mercator"})).unwrap(); s.run("layout.update", &json!({"element": frame.id, "center": [180.0, 0.0]})).unwrap(); let (pdf, said) = export(&mut s); assert!(!pdf.contains("/VP ["), "nothing georeferenced"); assert!(said.iter().any(|t| t.contains("crosses its projection's edge (the 180° meridian)")), "{said:?}"); // Another body's map isn't given an Earth system. s.run("layout.update", &json!({"element": frame.id, "center": [-45.0, -20.0]})).unwrap(); s.run("map.body", &json!({"name": "mars", "basemap": false})).unwrap(); let (pdf, said) = export(&mut s); assert!(!pdf.contains("/VP [")); assert!(said.iter().any(|t| t.contains("it is on another body than the Earth")), "{said:?}"); let _ = std::fs::remove_file(&path); } /// What a PDF doesn't georeference is one notice for each kind, with a count (#1191): a frame /// once however many pages it is on, two frames sharing a name two. A frame so on some pages of a /// series only is named with them, so that one georeferenced on the others doesn't read as having /// no coordinates anywhere in the PDF. #[test] fn a_pdf_says_which_frames_it_doesnt_georeference() { let mut s = Session::new(Project::default()); let mark = s.notice_mark(); crate::cmd::layout::georeference_notices(&mut s, &[], &[], 1); assert!(s.notices_since(mark).is_empty(), "everything georeferenced, nothing said"); let left = [ (1, 1, "Main".to_string(), "it is tilted"), (1, 1, "Main".to_string(), "it is tilted"), (1, 4, "Main".to_string(), "it shows the globe"), ]; crate::cmd::layout::georeference_notices(&mut s, &left, &[(1, 2, "Inset".to_string()), (1, 2, "Inset".to_string())], 1); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(said.len(), 2, "{said:?}"); assert_eq!(said[0], "The PDF doesn't georeference 2 map frames (Main: it is tilted, Main: it shows the globe)"); assert!(said[1].starts_with("The PDF georeferences 1 map frame by its corners") && said[1].ends_with("(Inset)"), "{}", said[1]); // Information, not warnings: nothing wrong is written. assert!(s.notices_since(mark).iter().all(|n| n.severity == rustgis_data::diagnostics::Severity::Info)); // A series of five pages: Main left out on pages 3 and 4 only (the first page's reason), the // inset by its corners on every page, an overview by its corners on page 2 only. let mark = s.notice_mark(); let left = [(3, 1, "Main".to_string(), "it crosses the 180° meridian"), (4, 1, "Main".to_string(), "it shows the globe")]; let mut corners: Vec<(usize, u32, String)> = (1..=5).map(|p| (p, 2, "Inset".to_string())).collect(); corners.push((2, 3, "Overview".to_string())); crate::cmd::layout::georeference_notices(&mut s, &left, &corners, 5); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(said[0], "The PDF doesn't georeference 1 map frame (Main on pages 3 and 4: it crosses the 180° meridian)"); assert!(said[1].ends_with("(Inset, Overview on page 2)"), "{}", said[1]); // Many pages are cut to ten, with how many more. let mark = s.notice_mark(); let left: Vec<_> = (1..=13).map(|p| (p, 1, "Main".to_string(), "it is tilted")).collect(); crate::cmd::layout::georeference_notices(&mut s, &left, &[], 20); let said = &s.notices_since(mark)[0].text; assert!(said.contains("(Main on pages 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 and 3 more: it is tilted)"), "{said}"); } /// Each page of a map series is georeferenced with its own view, not the first page's (#1191). #[test] fn a_map_series_pdf_georeferences_each_page_with_its_view() { let mut s = world(); s.run("map.projection", &json!({"type": "EPSG:3857"})).unwrap(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let pages = s.run("layout.series", &json!({"layer": "Lakes", "nameField": "name"})).unwrap()["pages"].as_u64().unwrap(); let path = std::env::temp_dir().join(format!("rustgis-geopdf-series-{}.pdf", std::process::id())); s.run("layout.export", &json!({"path": path, "dpi": 36})).unwrap(); let pdf = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); let _ = std::fs::remove_file(&path); let gpts: Vec<&str> = pdf.split("/GPTS [").skip(1).filter_map(|r| r.split(']').next()).collect(); assert_eq!(gpts.len() as u64, pages, "one viewport a page"); let mut distinct = gpts.clone(); distinct.sort_unstable(); distinct.dedup(); assert_eq!(distinct.len(), gpts.len(), "no page has another's corners"); } /// A frame drawn over another (an inset on the main map) is cut out of the other's viewport, so /// that a reader shows the main map's coordinates around the inset and not in it: none there when /// the inset is left out of georeferencing (tilted), the inset's own when it isn't (#1191). No two /// viewports overlap, and none goes without coordinates, which Acrobat Reader doesn't take. #[test] fn a_frame_drawn_over_another_is_cut_out_of_its_viewport() { let mut s = world(); s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); let frame = s.project.layouts[0].elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap().clone(); s.run("layout.update", &json!({"element": frame.id, "center": [-45.0, -20.0], "scale": 2_000_000.0})).unwrap(); // The inset shows the main map's place, as it is once moved there. let mut inset = s.project.layouts[0].elements.iter().find(|e| e.id == frame.id).unwrap().clone(); inset.id = 9_001; inset.name = "Inset".into(); (inset.x, inset.y, inset.w, inset.h) = (frame.x + 10.0, frame.y + 10.0, 40.0, 30.0); if let rustgis_map::layout::ElementKind::MapFrame { pitch, .. } = &mut inset.kind { *pitch = 30.0; } s.project.layouts[0].elements.push(inset); let inside = |v: &rustgis_render::pdf::Viewport, (x, y): (f64, f64)| { let [vx, vy, w, h] = v.rect_mm; x > vx && x < vx + w && y > vy && y < vy + h }; let (in_inset, beside) = ((frame.x + 30.0, frame.y + 25.0), (frame.x + 5.0, frame.y + 5.0)); let geo = rustgis_render::layout::frame_viewports(&s.project.layouts[0], &s.project.maps, 72.0); assert_eq!(geo.left_out.len(), 1, "{:?}", geo.left_out); assert_eq!(geo.viewports.len(), 4, "the main frame around the inset: above, below, left and right of it"); assert!(geo.viewports.iter().all(|v| v.name == frame.name && v.measure.projected)); assert!(!geo.viewports.iter().any(|v| inside(v, in_inset)) && geo.viewports.iter().any(|v| inside(v, beside))); // No two overlap. for (i, a) in geo.viewports.iter().enumerate() { for b in &geo.viewports[i + 1..] { let ([ax, ay, aw, ah], [bx, by, bw, bh]) = (a.rect_mm, b.rect_mm); let apart = ax + aw <= bx + 1e-9 || bx + bw <= ax + 1e-9 || ay + ah <= by + 1e-9 || by + bh <= ay + 1e-9; assert!(apart, "{:?} {:?}", a.rect_mm, b.rect_mm); } } let area: f64 = geo.viewports.iter().map(|v| v.rect_mm[2] * v.rect_mm[3]).sum(); let whole = frame.w * frame.h - 40.0 * 30.0; assert!((area - whole).abs() < 0.02 * whole, "{area} {whole}"); // Where the main map's pieces meet, they give the same place: the band above the inset ends // where the strip left of it starts. let (top, left) = (&geo.viewports[0], &geo.viewports[2]); let (a, b) = (top.measure.corners[3], left.measure.corners[0]); assert!((a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9, "{a:?} {b:?}"); // An inset that is georeferenced has its own viewport, drawn last, over no other. let inset = s.project.layouts[0].elements.iter_mut().find(|e| e.id == 9_001).unwrap(); if let rustgis_map::layout::ElementKind::MapFrame { pitch, .. } = &mut inset.kind { *pitch = 0.0; } let geo = rustgis_render::layout::frame_viewports(&s.project.layouts[0], &s.project.maps, 72.0); let names: Vec<&str> = geo.viewports.iter().map(|v| v.name.as_str()).collect(); assert_eq!(names, [frame.name.as_str(), frame.name.as_str(), frame.name.as_str(), frame.name.as_str(), "Inset"], "{:?}", geo.left_out); assert!(geo.left_out.is_empty() && inside(&geo.viewports[4], in_inset)); // Exactly the frame without the inset, as both are placed on the page: the pieces fill the // frame's box less the inset's, none reaches into the inset, and none overlaps another (above). let [ix, iy, iw, ih] = geo.viewports[4].rect_mm; let main = &geo.viewports[..4]; let (x0, y0) = main.iter().fold((f64::MAX, f64::MAX), |(x, y), v| (x.min(v.rect_mm[0]), y.min(v.rect_mm[1]))); let (x1, y1) = main.iter().fold((f64::MIN, f64::MIN), |(x, y), v| (x.max(v.rect_mm[0] + v.rect_mm[2]), y.max(v.rect_mm[1] + v.rect_mm[3]))); let area: f64 = main.iter().map(|v| v.rect_mm[2] * v.rect_mm[3]).sum(); assert!((area - ((x1 - x0) * (y1 - y0) - iw * ih)).abs() < 1e-6, "{area}"); for v in main { let [vx, vy, vw, vh] = v.rect_mm; assert!(vx + vw <= ix + 1e-9 || ix + iw <= vx + 1e-9 || vy + vh <= iy + 1e-9 || iy + ih <= vy + 1e-9, "{:?}", v.rect_mm); } // Their box is the frame's own, as it is placed with nothing over it. s.project.layouts[0].elements.iter_mut().find(|e| e.id == 9_001).unwrap().visible = false; let alone = rustgis_render::layout::frame_viewports(&s.project.layouts[0], &s.project.maps, 72.0); let [fx, fy, fw, fh] = alone.viewports[0].rect_mm; assert_eq!(alone.viewports.len(), 1); assert!([x0 - fx, y0 - fy, x1 - (fx + fw), y1 - (fy + fh)].iter().all(|d| d.abs() < 1e-9), "{:?}", (x0, y0, x1, y1)); // A round inset over it is left out, and said: its box would give its places around the circle, // where the page shows the main map. The main map is still cut around that box. let inset = s.project.layouts[0].elements.iter_mut().find(|e| e.id == 9_001).unwrap(); inset.visible = true; if let rustgis_map::layout::ElementKind::MapFrame { shape, .. } = &mut inset.kind { *shape = rustgis_map::layout::FrameShape::Circle; } let round = rustgis_render::layout::frame_viewports(&s.project.layouts[0], &s.project.maps, 72.0); assert!(round.viewports.iter().all(|v| v.name == frame.name) && round.viewports.len() == 4, "{:?}", round.left_out); assert!(round.left_out.iter().any(|(id, _, why)| *id == 9_001 && why.contains("isn't a rectangle")), "{:?}", round.left_out); // Covered all over by a frame drawn over it, it has no viewport, and that is said. let over = s.project.layouts[0].elements.iter_mut().find(|e| e.id == 9_001).unwrap(); (over.visible, over.x, over.y, over.w, over.h) = (true, frame.x - 1.0, frame.y - 1.0, frame.w + 2.0, frame.h + 2.0); let covered = rustgis_render::layout::frame_viewports(&s.project.layouts[0], &s.project.maps, 72.0); assert!(covered.viewports.iter().all(|v| v.name == "Inset"), "{:?}", covered.viewports.len()); assert!( covered.left_out.contains(&(frame.id, frame.name.clone(), "the frames drawn over it cover all of it")), "{:?}", covered.left_out ); } #[test] fn layer_invert_ramp_reverses_the_class_colors() { // #1355: Invert Color Ramp reverses the order of a layer's class colours. let mut s = world(); let colors = |s: &Session| -> Vec { match &s.map().layer_by_name("Countries").unwrap().renderer { rustgis_map::Renderer::Graduated { symbols, .. } => symbols.iter().map(|y| y.fill).collect(), rustgis_map::Renderer::Unique { classes, .. } => classes.iter().map(|c| c.1.fill).collect(), _ => vec![], } }; // A single symbol has no color scheme to invert. s.run("layer.symbology", &json!({"layer": "Countries", "type": "single"})).unwrap(); assert!(s.run("layer.invertRamp", &json!({"layer": "Countries"})).is_err()); s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap(); let before = colors(&s); assert_eq!(before.len(), 5); let mark = s.notice_mark(); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); assert!(s.notices_since(mark).is_empty(), "nothing was left as it was, so nothing is said: {:?}", s.notices_since(mark)); let mut reversed = before.clone(); reversed.reverse(); assert_eq!(colors(&s), reversed); // Twice is the original; one undo step undoes one inversion. s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); assert_eq!(colors(&s), before); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); s.run("edit.undo", &Value::Null).unwrap(); assert_eq!(colors(&s), before); // Unique Values too. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); let before = colors(&s); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); let mut reversed = before.clone(); reversed.reverse(); assert_eq!(colors(&s), reversed); assert!(s.run("layer.invertRamp", &json!({"layer": "Nope"})).is_err()); // A colour set by hand travels with the reversal, and each polygon fill keeps its own opacity. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap(); s.run("layer.classStyle", &json!({"layer": "Countries", "index": 0, "color": "#11223344"})).unwrap(); let before = colors(&s); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); let after = colors(&s); assert_eq!(after.len(), 5); assert_eq!(after[4].with_alpha(0), before[0].with_alpha(0), "the edited colour moved to the other end"); assert_eq!(after[4].a(), before[4].a(), "a fill keeps its own opacity"); assert_eq!(after[0].a(), before[0].a()); assert_eq!(before[0].a(), 0x44); // A class drawn with a stack of fills of its own is drawn instead of its symbol, so the // reversal can't reach it: it keeps its fills, and the command says so. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 5})).unwrap(); s.run("layer.fillLayers", &json!({"layer": "Countries", "index": 0, "fills": [{"color": "#FF0000", "pattern": "solid"}]})).unwrap(); let own = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { rustgis_map::Renderer::Graduated { class_fills, .. } => class_fills.clone(), _ => Default::default(), }; let mark = s.notice_mark(); let before = colors(&s); let fills_before = own(&s); assert_eq!(fills_before.keys().copied().collect::>(), vec![0]); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); assert_eq!(own(&s), fills_before, "the class keeps its own fills, as they were"); // Class 0 keeps its colour, and the others reverse among themselves. let after = colors(&s); assert_eq!(after[0], before[0]); assert_eq!(after[1..], [before[4], before[3], before[2], before[1]]); let notices = s.notices_since(mark); assert!( notices.iter().any(|n| n.text.starts_with("1 class(es) drawn with fills or strokes of their own were left as they are")), "{notices:?}" ); // A Stretch raster switches its invert option, and other rasters are refused. use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let gt = GeoTransform { origin_x: 0.0, origin_y: 2.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new("dem", 2, 2, vec![vec![1.0, 2.0, 3.0, 4.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); s.map_mut().add_raster("dem", rustgis_map::RasterLayer::from_path(r, "dem.tif")); s.run("raster.symbology", &json!({"layer": "dem", "type": "stretch", "ramp": "Terrain"})).unwrap(); let inverted = |s: &Session| match s.map().layer_by_name("dem").and_then(|l| l.raster.as_ref()).map(|r| r.style.renderer.clone()) { Some(rustgis_map::RasterRenderer::Stretch { invert, .. }) => Some(invert), _ => None, }; assert_eq!(inverted(&s), Some(false)); s.run("layer.invertRamp", &json!({"layer": "dem"})).unwrap(); assert_eq!(inverted(&s), Some(true)); s.run("layer.invertRamp", &json!({"layer": "dem"})).unwrap(); assert_eq!(inverted(&s), Some(false)); s.run("raster.symbology", &json!({"layer": "dem", "type": "classify", "classes": 2, "method": "equalInterval"})).unwrap(); assert!(s.run("layer.invertRamp", &json!({"layer": "dem"})).is_err(), "a classified raster has no invert option"); } #[test] fn layer_invert_ramp_says_when_the_layers_own_stack_hides_the_colors() { // #1355: classes without a stack of their own are drawn from the layer's `line_strokes` or // `fill_layers`; when every layer of it sets its own color, the color scheme shows in none of // them, and the command says so (the colors are reversed all the same). let notices = |s: &mut Session, layer: &str| -> Vec { let mark = s.notice_mark(); s.run("layer.invertRamp", &json!({"layer": layer})).unwrap(); s.notices_since(mark).iter().map(|n| n.text.clone()).collect() }; let mut s = world(); // A line layer: strokes that all set a color hide the symbol; a core that follows the class doesn't. s.run("layer.symbology", &json!({"layer": "Rivers", "type": "graduated", "field": "scalerank", "classes": 4})).unwrap(); assert!(notices(&mut s, "Rivers").is_empty(), "no stack, nothing to say"); let own = json!([{"color": "#000000", "width": 5}, {"color": "#FFFFFF", "width": 2}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": own})).unwrap(); let said = notices(&mut s, "Rivers"); assert_eq!(said.len(), 1, "{said:?}"); assert!(said[0].starts_with("The layer's stacked strokes set their own colors"), "{said:?}"); let casing_and_core = json!([{"color": "#000000", "width": 5}, {"width": 2}]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": casing_and_core})).unwrap(); assert!(notices(&mut s, "Rivers").is_empty(), "the core follows the class, so the scheme shows"); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": []})).unwrap(); assert!(notices(&mut s, "Rivers").is_empty()); // A polygon layer: its stacked fills. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 4})).unwrap(); s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap(); let said = notices(&mut s, "Countries"); assert_eq!(said.len(), 1, "{said:?}"); assert!(said[0].starts_with("The layer's stacked fills set their own colors"), "{said:?}"); s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "solid"}]})).unwrap(); assert!(notices(&mut s, "Countries").is_empty(), "a fill that follows the class shows the scheme"); // The notice about the layer's stack comes only when some classes changed places: with every // class drawn from a stack of its own, none did, and the layer's stack is not what shows. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 2})).unwrap(); s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap(); for index in 0..2 { s.run("layer.fillLayers", &json!({"layer": "Countries", "index": index, "fills": [{"color": "#AA0000", "pattern": "solid"}]})) .unwrap(); } let said = notices(&mut s, "Countries"); assert_eq!(said.len(), 1, "{said:?}"); assert!(said[0].starts_with("2 class(es) drawn with fills or strokes of their own"), "{said:?}"); // Their own stacks replace the layer's even where they follow the class, so the layer's stack // is not what shows and the command says nothing of it. for index in 0..2 { s.run("layer.fillLayers", &json!({"layer": "Countries", "index": index, "fills": [{"pattern": "solid"}]})).unwrap(); } assert!(notices(&mut s, "Countries").is_empty(), "every class has a stack of its own"); // One class left on the layer's stack is enough to say it. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 3})).unwrap(); s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"color": "#00AA00", "pattern": "solid"}]})).unwrap(); s.run("layer.fillLayers", &json!({"layer": "Countries", "index": 0, "fills": [{"pattern": "solid"}]})).unwrap(); let said = notices(&mut s, "Countries"); assert_eq!(said.len(), 1, "{said:?}"); assert!(said[0].starts_with("The layer's stacked fills set their own colors"), "{said:?}"); } #[test] fn layer_invert_ramp_says_when_symbol_by_expression_sets_colors() { // #1355: Symbol by Expression colors are drawn over the class colors, so the reversal shows only // where the expression gives none; with no color expression (a size one doesn't set colors) it is quiet. let notices = |s: &mut Session| -> Vec { let mark = s.notice_mark(); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); s.notices_since(mark).iter().map(|n| n.text.clone()).collect() }; let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 4})).unwrap(); assert!(notices(&mut s).is_empty()); s.run("layer.symbolBy", &json!({"layer": "Countries", "strokeWidth": "2"})).unwrap(); assert!(notices(&mut s).is_empty(), "a width is no color"); s.run("layer.symbolBy", &json!({"layer": "Countries", "fill": "if(pop_est > 1e8, '#ff0000', NULL)"})).unwrap(); let said = notices(&mut s); assert_eq!(said.len(), 1, "{said:?}"); assert!(said[0].starts_with("The layer's Symbol by Expression sets some features' colors"), "{said:?}"); s.run("layer.symbolBy", &json!({"layer": "Countries", "fill": null, "stroke": "'#00ff00'"})).unwrap(); assert_eq!(notices(&mut s).len(), 1, "a stroke color counts too"); } #[test] fn layer_invert_ramp_says_when_there_is_nothing_to_invert() { // #1355: with fewer than two classes showing the scheme, an inversion would change nothing, so // the command refuses rather than succeed silently. A raster's Invert is a switch and is no // class count, and a class kept for its own stack has its own notice. let mut s = world(); s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); let classes = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { rustgis_map::Renderer::Graduated { symbols, .. } => symbols.len(), _ => 0, }; assert_eq!(classes(&s), 1, "one class to invert"); let err = s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap_err(); assert!(err.contains("nothing to invert"), "{err}"); // Two classes reverse, with no notice. s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 2})).unwrap(); let mark = s.notice_mark(); s.run("layer.invertRamp", &json!({"layer": "Countries"})).unwrap(); assert!(s.notices_since(mark).is_empty()); } #[test] fn layer_invert_ramp_refuses_a_graphics_layer() { // #1355: a graphics layer draws each feature in its own style, whatever its renderer holds, so // reversing the renderer's colors would change nothing and say nothing. let mut s = world(); s.run("graphics.add", &json!({"shape": "rectangle", "points": [[0, 45], [10, 55]], "fill": "#FF000040"})).unwrap(); let id = s.map().layers.iter().find(|l| l.graphics).map(|l| l.id).unwrap(); let err = s.run("layer.invertRamp", &json!({"layer": id})).unwrap_err(); assert!(err.contains("graphics layer"), "{err}"); } /// Calculate Field keeps a decimal result as it is in a decimal field, rather than rounding it to /// the six places its text shows, and says what a whole-number field cuts: a fraction, or a number /// past the 64-bit range (null). Results the field holds give no notice. #[test] fn calculate_field_keeps_decimals_and_says_what_whole_numbers_cut() { use rustgis_data::Value as V; let mut s = Session::new(Project::default()); let points = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"y": 2020, "t": "2.7"}, "geometry": {"type": "Point", "coordinates": [0, 0]}}, {"type": "Feature", "properties": {"y": 14, "t": "12"}, "geometry": {"type": "Point", "coordinates": [1, 0]}}, {"type": "Feature", "properties": {"y": -15, "t": "n/a"}, "geometry": {"type": "Point", "coordinates": [2, 0]}}]}); s.run("layer.addDataText", &json!({"fileName": "calc.geojson", "text": points.to_string()})).unwrap(); s.run("edit.addField", &json!({"layer": "calc", "name": "d", "type": "double"})).unwrap(); s.run("edit.addField", &json!({"layer": "calc", "name": "i", "type": "long"})).unwrap(); // Each run's notices, and the field's values after it. fn calc(s: &mut Session, field: &str, expression: &str) -> (Vec, Vec) { let mark = s.notice_mark(); s.run("edit.calculateField", &json!({"layer": "calc", "field": field, "expression": expression})).unwrap(); let notices: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); let ds = &s.map().layer_by_name("calc").unwrap().dataset; let fi = ds.field_index(field).unwrap(); (notices, (0..3).map(|r| ds.value(r, fi).clone()).collect::>()) } let none: Vec = Vec::new(); // Decimals as they are: a third, a billionth, and one past what six places times a million hold. let third = 1.0 / 3.0; assert_eq!(calc(&mut s, "d", "1 / 3"), (none.clone(), vec![V::Float(third); 3])); let billionths = [2020.0, 14.0, -15.0].map(|y: f64| V::Float(y * 0.000000001)).to_vec(); assert_eq!(calc(&mut s, "d", "y * 0.000000001"), (none.clone(), billionths)); assert_eq!(calc(&mut s, "d", "y * 1e300").1[0], V::Float(2020.0 * 1e300), "not infinite"); assert_eq!(calc(&mut s, "d", "y / 2"), (none.clone(), vec![V::Float(1010.0), V::Float(7.0), V::Float(-7.5)])); // A whole-number field: 2020 / 7 and -15 / 7 lose their fraction (toward zero); 14 / 7 is 2. let cut = |n: &str| { let their = if n.starts_with("1 ") { "its" } else { "their" }; format!("Calculate Field: 'i' holds whole numbers, so {n} lost {their} fraction") }; assert_eq!(calc(&mut s, "i", "y / 7"), (vec![cut("2 results")], vec![V::Int(288), V::Int(2), V::Int(-2)])); assert_eq!(calc(&mut s, "i", "y * 2"), (none.clone(), vec![V::Int(4040), V::Int(28), V::Int(-30)])); // Past the 64-bit range: null, and said; 14e17 and -15e17 fit, exactly. let past = |n: &str| format!("Calculate Field: 'i' is null for {n} past the 64-bit integer range"); let (notices, values) = calc(&mut s, "i", "y * 1e17"); assert_eq!(notices, [past("1 result")]); assert_eq!(values, [V::Null, V::Int(1_400_000_000_000_000_000), V::Int(-1_500_000_000_000_000_000)]); assert_eq!(calc(&mut s, "i", "y * 1e18").0, [past("3 results")]); // The range's ends: -2^63 is i64::MIN; 2^63 is one past i64::MAX. assert_eq!(calc(&mut s, "i", "y * 0 - 9223372036854775808"), (none.clone(), vec![V::Int(i64::MIN); 3])); assert_eq!(calc(&mut s, "i", "y * 0 + 9223372036854775808"), (vec![past("3 results")], vec![V::Null; 3])); // Text written as a number: a decimal loses its fraction, a whole number is exact; text that // isn't a number is null, and said. let unread = "Calculate Field: 'i' is null in 1 row, whose result isn't a Long value".to_string(); assert_eq!(calc(&mut s, "i", "t"), (vec![cut("1 result"), unread.clone()], vec![V::Int(2), V::Int(12), V::Null])); // Decimal text at the range's ends is read from its digits: i64::MAX written with ".0" fits, // and a fraction on i64::MIN is cut, not taken as one past the range. s.run("edit.addField", &json!({"layer": "calc", "name": "t2", "type": "text"})).unwrap(); let texts = |s: &mut Session, values: [&str; 3]| { for (row, v) in values.iter().enumerate() { s.run("edit.setValue", &json!({"layer": "calc", "row": row, "field": "t2", "value": v})).unwrap(); } }; texts(&mut s, ["9223372036854775807.0", "-9223372036854775808.9", "-.5"]); assert_eq!(calc(&mut s, "i", "t2"), (vec![cut("2 results")], vec![V::Int(i64::MAX), V::Int(i64::MIN), V::Int(0)])); texts(&mut s, ["9223372036854775808.0", "12.000", "1.5e3"]); assert_eq!(calc(&mut s, "i", "t2"), (vec![past("1 result")], vec![V::Null, V::Int(12), V::Int(1500)])); // Whole-number text past the range is read from its digits too: just below i64::MIN is past // it, though as a decimal it rounds to -2^63, which is i64::MIN. texts(&mut s, ["-9223372036854775809", "-9223372036854775900", "+9223372036854775808"]); assert_eq!(calc(&mut s, "i", "t2"), (vec![past("3 results")], vec![V::Null; 3])); // Words the decimal reader takes aren't numbers written as text: null, said as not numbers, // not as past the range; a number too large for a decimal is past it. texts(&mut s, ["NaN", "inf", "1e400"]); let not_numbers = "Calculate Field: 'i' is null for 2 results that aren't numbers".to_string(); assert_eq!(calc(&mut s, "i", "t2"), (vec![past("1 result"), not_numbers], vec![V::Null; 3])); // With an exponent, text is read as a decimal: 0.25 loses its fraction, 3 is whole; "-" is // no number. texts(&mut s, ["2.5e-1", "3e0", "-"]); assert_eq!(calc(&mut s, "i", "t2"), (vec![cut("1 result"), unread.clone()], vec![V::Int(0), V::Int(3), V::Null])); // Other commands that write a number into a whole-number field keep their earlier conversion // past the range (the range's end): only Calculate Field and calculation rules say what they // cut, and make such a result null. s.run("edit.setValue", &json!({"layer": "calc", "row": 2, "field": "i", "value": 1e30})).unwrap(); s.run("edit.setValue", &json!({"layer": "calc", "row": 1, "field": "d", "value": third})).unwrap(); let ds = &s.map().layer_by_name("calc").unwrap().dataset; assert_eq!(ds.value(2, ds.field_index("i").unwrap()), &V::Int(i64::MAX)); assert_eq!(ds.value(1, ds.field_index("d").unwrap()), &V::Float(third), "a decimal set as it is"); // One fraction: the singular. s.run("select.byAttributes", &json!({"layer": "calc", "where": "y = 2020"})).unwrap(); let mark = s.notice_mark(); s.run("edit.calculateField", &json!({"layer": "calc", "field": "i", "expression": "y / 7"})).unwrap(); let notices: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(notices, ["Calculate Field: 'i' holds whole numbers, so 1 result lost its fraction"]); // A data-design calculation rule writes through the same conversion, and says so on each edit // it cuts, once the edit is kept. s.run("select.clear", &json!({})).unwrap(); let rule = json!({"layer": "calc", "name": "Seventh", "kind": "calculation", "field": "i", "expression": "y / 7"}); s.run("data.attributeRule", &rule).unwrap(); let edit = |s: &mut Session, y: i64| { let mark = s.notice_mark(); s.run("edit.setValue", &json!({"layer": "calc", "row": 1, "field": "y", "value": y})).map(|_| ())?; let ds = &s.map().layer_by_name("calc").unwrap().dataset; let notices: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); Ok::<_, String>((notices, ds.value(1, ds.field_index("i").unwrap()).clone())) }; let rule_cut = "Attribute rule Seventh: 'i' holds whole numbers, so 1 result lost its fraction".to_string(); assert_eq!(edit(&mut s, 15), Ok((vec![rule_cut], V::Int(2)))); assert_eq!(edit(&mut s, 21), Ok((none.clone(), V::Int(3)))); let rule = json!({"layer": "calc", "name": "Huge", "kind": "calculation", "field": "d", "expression": "y / 3"}); s.run("data.attributeRule", &rule).unwrap(); let d = |s: &Session| { let ds = &s.map().layer_by_name("calc").unwrap().dataset; ds.value(1, ds.field_index("d").unwrap()).clone() }; edit(&mut s, 22).unwrap(); assert_eq!(d(&s), V::Float(22.0 / 3.0), "a decimal rule's result as it is"); // Each step says what it cut, even when a later rule writes the same field: the later rule may // read the cut value (here `i * 2` reads Seventh's 2, from 15 / 7). let rule = json!({"layer": "calc", "name": "Double", "kind": "calculation", "field": "i", "expression": "i * 2"}); s.run("data.attributeRule", &rule).unwrap(); let seventh = |n: &str| { let their = if n.starts_with("1 ") { "its" } else { "their" }; format!("Attribute rule Seventh: 'i' holds whole numbers, so {n} lost {their} fraction") }; assert_eq!(edit(&mut s, 15), Ok((vec![seventh("1 result")], V::Int(4)))); // Calculate Field says what it cut too, after the rules that ran in its edit. let (notices, values) = calc(&mut s, "i", "y / 7"); assert_eq!((notices, values[1].clone()), (vec![seventh("3 results"), cut("3 results")], V::Int(4))); s.run("data.attributeRule", &json!({"layer": "calc", "name": "Double", "remove": true})).unwrap(); s.run("data.attributeRule", &json!({"layer": "calc", "name": "Seventh", "remove": true})).unwrap(); // A rejected edit changes nothing, and says nothing of the rule. let limit = json!({"layer": "calc", "name": "Small", "kind": "constraint", "expression": "y < 100"}); s.run("data.attributeRule", &limit).unwrap(); let mark = s.notice_mark(); assert!(s.run("edit.setValue", &json!({"layer": "calc", "row": 1, "field": "y", "value": 101})).is_err()); assert!(s.notices_since(mark).iter().all(|n| !n.text.starts_with("Attribute rule")), "{:?}", s.notices_since(mark)); } /// A raster function started on another thread says a `keep` it can't read when it starts, and a /// function that doesn't read `keep` says nothing of it (#1600). #[cfg(not(target_arch = "wasm32"))] #[test] fn background_raster_function_says_unread_keep() { use crate::cmd::workflows::raster_start; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); let said = |s: &Session| s.notices.iter().filter(|n| n.text.contains("keep = \"auto\" (read as false)")).count(); s.run("raster.function", &json!({"layer": dem, "function": "slope", "keep": "auto"})).unwrap(); assert_eq!(said(&s), 0, "slope doesn't read keep"); let p = json!({"layer": dem, "function": "remap", "ranges": [[0, 300, 1]], "keep": "auto"}); raster_start(&mut s, "raster.function", &p).unwrap(); assert_eq!(said(&s), 1, "{:?}", s.notices.last()); let notice = s.notices.iter().rev().find(|n| n.text.contains("keep = \"auto\"")).unwrap().clone(); let records = s.diagnostics.records(); assert!(records.iter().any(|r| r.message == notice.text && r.source == "raster.function"), "{records:?}"); // Each step of a chain, likewise. let chain = json!({"layer": dem, "steps": [{"function": "slope", "keep": "auto"}, {"function": "remap", "ranges": [[0, 90, 1]], "keep": "auto"}]}); raster_start(&mut s, "raster.functionChain", &chain).unwrap(); assert_eq!(said(&s), 2, "{:?}", s.notices.last()); } /// Viewshed and Interpolate made on another thread, as the Analysis pane runs them (#1291 step 3): /// the raster handed over (`ran`) is the one the command makes here, a cancelled run is an error /// saying so, and a run whose project was closed meanwhile adds nothing. #[cfg(not(target_arch = "wasm32"))] #[test] fn analysis_workflows_run_on_another_thread() { use crate::cmd::workflows::{raster_land, raster_start}; use rustgis_analysis::control::Control; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); let with_ran = |p: &Value, id: u64| { let mut q = p.clone(); q["ran"] = json!(id); q }; let pixels = |s: &Session, v: &Value| -> Vec { let l = s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap(); l.raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data.iter().map(|x| x.to_bits()).collect() }; for (command, p) in [ ("analysis.viewshed", json!({"layer": dem, "lon": 10.0055, "lat": 45.0395, "height": 2})), ("analysis.interpolate", json!({"layer": "Cities", "field": "pop_max", "cellSize": 2})), ] { let here = s.run(command, &p).unwrap(); let run = raster_start(&mut s, command, &p).unwrap(); let id = run.id; let control = Control::new(); let made = std::thread::spawn(move || run.run(&control)).join().unwrap().unwrap(); assert_eq!(raster_land(&mut s, id, made), Ok(true)); let mut with = p.clone(); with["ran"] = json!(id); let there = s.run(command, &with).unwrap(); assert_eq!(pixels(&s, &here), pixels(&s, &there), "{command}"); assert_eq!(here["visibleCells"], there["visibleCells"], "{command}"); // Used up: a second hand-over is refused, not worked out again. assert!(s.run(command, &with).unwrap_err().contains("no longer kept"), "{command}"); // Another command's raster isn't taken, and stays for its own. let run = raster_start(&mut s, command, &p).unwrap(); let id = run.id; assert_eq!(raster_land(&mut s, id, run.run(&Control::new()).unwrap()), Ok(true)); let other = if command == "analysis.viewshed" { "analysis.interpolate" } else { "analysis.viewshed" }; assert!(s.run(other, &with_ran(&p, id)).unwrap_err().contains("another command"), "{command}"); s.run(command, &with_ran(&p, id)).unwrap(); // Downloads meanwhile, however many, don't push a run out. let run = raster_start(&mut s, command, &p).unwrap(); let id = run.id; for _ in 0..100 { s.download_started(); } assert_eq!(raster_land(&mut s, id, run.run(&Control::new()).unwrap()), Ok(true), "{command}"); s.run(command, &with_ran(&p, id)).unwrap(); // Cancelled: an error saying so, and nothing kept. let run = raster_start(&mut s, command, &p).unwrap(); let control = Control::new(); control.cancel(); let e = run.run(&control).err().unwrap(); assert!(e.contains("was cancelled"), "{e}"); } // A run whose map is no longer the active one isn't added, and one whose project was closed // meanwhile is dropped. let p = json!({"layer": dem, "lon": 10.0055, "lat": 45.0395}); let start = |s: &mut Session| { let run = raster_start(s, "analysis.viewshed", &p).unwrap(); (run.id, run.run(&Control::new()).unwrap()) }; let first = s.map().name.clone(); let (id, made) = start(&mut s); s.run("map.new", &json!({"name": "Other"})).unwrap(); assert!(raster_land(&mut s, id, made).unwrap_err().contains("another map")); s.run("map.activate", &json!({"name": first})).unwrap(); let (id, made) = start(&mut s); s.run("project.new", &Value::Null).unwrap(); assert_eq!(raster_land(&mut s, id, made), Ok(false)); assert!(raster_start(&mut s, "analysis.lineOfSight", &p).is_err()); } /// `raster.rasterize` burns a polygon layer onto a grid given as `raster.project`'s is: holes cut /// out, a later polygon over an earlier one, a text field's codes in a table, numbers in the /// smallest pixel type that holds them, the selection alone, another raster's grid exactly, and /// bad parameters refused before anything is made. #[test] fn polygons_burned_onto_a_chosen_grid() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let mut s = world(); let ring = |x0: f64, y0: f64, x1: f64, y1: f64| { vec![Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0)] }; let fields = vec![Field::new("unit", FieldType::Text), Field::new("clay", FieldType::Double), Field::new("code", FieldType::Integer)]; let mut soils = Dataset::new("soils", GeometryType::Polygon, fields.clone()); // A square with a hole, a second square over part of it, and a polygon with no unit. let holed = Geometry::Polygon(vec![vec![ring(0.0, 0.0, 8.0, 8.0), ring(3.0, 3.0, 5.0, 5.0)]]); soils.push(Some(holed), vec![V::Text("Latossolo".into()), V::Float(35.5), V::Int(2)]); soils.push( Some(Geometry::Polygon(vec![vec![ring(4.0, 4.0, 10.0, 10.0)]])), vec![V::Text("Argissolo".into()), V::Float(20.25), V::Int(7)], ); soils.push(Some(Geometry::Polygon(vec![vec![ring(20.0, 0.0, 21.0, 1.0)]])), vec![V::Null, V::Null, V::Null]); crate::cmd::layer::add_dataset(&mut s, soils); // The value of the cell whose centre is (x, y) on a raster of 1° cells whose top is at 10°. let at = |r: &Raster, x: f64, y: f64| r.value(0, x.floor() as i64, (10.0 - y).floor() as i64); // A text field: codes 1 and 2 in sorted order, listed in a table; the polygon with no unit // is skipped, so the grid covers the other two (10 × 10 cells of 1°). let mark = s.notice_mark(); let v = s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "cellSize": 1})).unwrap(); assert_eq!( (v["name"].as_str(), v["crs"].as_str(), v["dataType"].as_str()), (Some("soils raster"), Some("EPSG:4326"), Some("UInt8")), "{v}" ); assert_eq!((v["width"].as_u64(), v["height"].as_u64(), v["corner"].clone()), (Some(10), Some(10), json!([0.0, 10.0])), "{v}"); assert_eq!(v["classes"], json!([{"code": 1, "value": "Argissolo"}, {"code": 2, "value": "Latossolo"}])); assert_eq!((v["features"].as_u64(), v["skipped"].clone()), (Some(2), json!({"noValue": 1}))); assert!(s.notices_since(mark).iter().any(|n| n.text == "soils: features skipped: 1 with no value"), "lossy input is said"); // 60 cells of the holed square and 36 of the other, 15 of them in both. assert_eq!(v["cells"].as_u64(), Some(81)); let r = out_raster(&s, &v); assert_eq!(at(&r, 1.5, 1.5), Some(2.0)); assert_eq!(at(&r, 3.5, 3.5), None, "the hole stays out"); assert_eq!((at(&r, 4.5, 4.5), at(&r, 6.5, 6.5), at(&r, 9.5, 9.5)), (Some(1.0), Some(1.0), Some(1.0)), "the later polygon wins"); assert_eq!(at(&r, 9.5, 0.5), None, "no polygon there"); let table = s.project.tables.iter().find(|t| t.name == "soils raster classes").expect("the codes' table"); assert_eq!((table.features.len(), table.value(1, 0), table.value(1, 1)), (2, &V::Int(2), &V::Text("Latossolo".into()))); assert!(matches!( s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().style.renderer, rustgis_map::RasterRenderer::UniqueValues { .. } )); // A floating-point field keeps its values; a whole-number one fits in bytes; one value gives // a mask. let clay = s.run("raster.rasterize", &json!({"layer": "soils", "field": "clay", "cellSize": 1, "name": "clay"})).unwrap(); let r = out_raster(&s, &clay); assert_eq!((clay["dataType"].as_str(), at(&r, 1.5, 1.5), at(&r, 9.5, 9.5)), (Some("Float32"), Some(35.5), Some(20.25))); let code = s.run("raster.rasterize", &json!({"layer": "soils", "field": "code", "cellSize": 1, "nodata": 300, "name": "code"})).unwrap(); let r = out_raster(&s, &code); assert_eq!((code["dataType"].as_str(), at(&r, 1.5, 1.5), at(&r, 3.5, 3.5)), (Some("UInt16"), Some(2.0), None), "{code}"); let mask = s.run("raster.rasterize", &json!({"layer": "soils", "cellSize": 1, "name": "mask"})).unwrap(); let r = out_raster(&s, &mask); // With no field, the polygon with no unit is burned too: the grid reaches it. assert_eq!((mask["dataType"].as_str(), mask["width"].as_u64(), at(&r, 20.5, 0.5)), (Some("UInt8"), Some(21), Some(1.0)), "{mask}"); // The selection alone: the second square, 6 × 6 cells. s.run("select.byAttributes", &json!({"layer": "soils", "where": "unit = 'Argissolo'"})).unwrap(); let mark = s.notice_mark(); let sel = s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "cellSize": 1, "name": "selected"})).unwrap(); assert_eq!( (sel["width"].as_u64(), sel["cells"].as_u64(), sel["classes"].clone()), (Some(6), Some(36), json!([{"code": 1, "value": "Argissolo"}])) ); assert!(s.notices_since(mark).is_empty() && sel.get("skipped").is_none(), "clean input says nothing: {sel}"); s.run("select.clear", &json!({})).unwrap(); // On a grid projected from a raster: exactly that grid, and the cells in place there. let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 0.5, pixel_height: -0.5 }; let dem = Raster::new("dem", 20, 20, vec![vec![1.0; 400]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = rustgis_data::raster::geotiff::write(&dem, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "dem", rustgis_map::RasterLayer::from_bytes(dem, "dem.tif", bytes)).unwrap(); let merc = s.run("raster.project", &json!({"layer": "dem", "crs": 3857, "cellSize": 50_000, "name": "dem merc"})).unwrap(); let on = s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "grid": "dem merc", "name": "on dem"})).unwrap(); let (t, r) = (out_raster(&s, &merc), out_raster(&s, &on)); assert_eq!((r.width, r.height, r.transform, r.crs), (t.width, t.height, t.transform, t.crs)); let cell = |lon: f64, lat: f64| { let p = r.transform.to_pixel(r.transformer().from_lonlat(Coord::new(lon, lat)).unwrap()); r.value(0, p.0.floor() as i64, p.1.floor() as i64) }; assert_eq!((cell(1.5, 1.5), cell(9.0, 9.0), cell(3.8, 3.8)), (Some(2.0), Some(1.0), None), "{on}"); // The same crs and cellSize as a projection line up with it, without the template. let same = s.run("raster.rasterize", &json!({"layer": "soils", "crs": 3857, "cellSize": 50_000, "name": "same"})).unwrap(); assert_eq!((same["corner"][0].as_f64().unwrap() / 50_000.0).fract(), 0.0, "{same}"); // Written to a file, as bytes with 0 for no-data. let dir = std::env::temp_dir().join(format!("rustgis-rasterize-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("soils.tif"); let _ = std::fs::remove_file(&path); let f = s .run( "raster.rasterize", &json!({"layer": "soils", "field": "unit", "cellSize": 1, "path": path.display().to_string(), "name": "file"}), ) .unwrap(); assert!(f["cog"]["bytes"].as_u64().is_some_and(|b| b > 0), "{f}"); let back = rustgis_data::raster::read_bytes("soils.tif", &std::fs::read(&path).unwrap()).unwrap(); assert_eq!((back.data_type.as_str(), back.nodata, at(&back, 1.5, 1.5), at(&back, 3.5, 3.5)), ("UInt8", Some(0.0), Some(2.0), None)); let _ = std::fs::remove_dir_all(&dir); // A whole number past what a pixel holds exactly is refused; a decimal one is rounded, and // said, and one that would round to the no-data value is refused. A polygon that holds no cell // centre is said too. let mut big = Dataset::new("big", GeometryType::Polygon, fields); big.push(Some(Geometry::Polygon(vec![vec![ring(0.0, 0.0, 1.0, 1.0)]])), vec![V::Null, V::Float(35.1), V::Int(20_000_001)]); big.push(Some(Geometry::Polygon(vec![vec![ring(5.6, 5.6, 5.7, 5.7)]])), vec![V::Null, V::Float(1e-50), V::Null]); crate::cmd::layer::add_dataset(&mut s, big); let e = s.run("raster.rasterize", &json!({"layer": "big", "field": "code", "cellSize": 1})).unwrap_err(); assert!(e.contains("text field"), "{e}"); let e = s.run("raster.rasterize", &json!({"layer": "big", "field": "clay", "cellSize": 1})).unwrap_err(); assert!(e.contains("no-data value"), "{e}"); let mark = s.notice_mark(); let v = s.run("raster.rasterize", &json!({"layer": "big", "field": "clay", "nodata": -1, "cellSize": 1, "name": "rounded"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!((v["features"].as_u64(), v["skipped"].clone()), (Some(1), json!({"noCell": 1})), "{v}"); assert!(said.iter().any(|n| n == "big: 2 value(s) of clay rounded to the raster's 32-bit pixels"), "{said:?}"); assert!( said.iter().any(|n| n == "big: features skipped: 1 that hold no cell centre (narrower than a cell, or off the grid)"), "{said:?}" ); // A template read from its overview: its grid is taken, and said. let mut over = Raster::new("over", 20, 20, vec![vec![1.0; 400]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); over.overview = Some((80, 80)); let bytes = rustgis_data::raster::geotiff::write(&over, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "over", rustgis_map::RasterLayer::from_bytes(over, "over.tif", bytes)).unwrap(); let mark = s.notice_mark(); s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "grid": "over", "name": "on over"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|n| n == "over: the grid is its 20 × 20 overview's, of the 80 × 80 raster"), "{said:?}"); // Refused before anything is made. let bad = [ json!({"cellSize": 1}), json!({"layer": "dem", "cellSize": 1}), json!({"layer": "Cities", "cellSize": 1}), json!({"layer": "soils", "field": "unit", "value": 3, "cellSize": 1}), json!({"layer": "soils", "field": "nope", "cellSize": 1}), json!({"layer": "soils", "field": 5, "cellSize": 1}), json!({"layer": "soils", "value": "x", "cellSize": 1}), json!({"layer": "soils", "value": 1e300, "cellSize": 1}), json!({"layer": "soils", "nodata": "x", "cellSize": 1}), json!({"layer": "soils", "nodata": 1, "cellSize": 1}), json!({"layer": "soils", "field": "code", "nodata": 7, "cellSize": 1}), json!({"layer": "soils"}), json!({"layer": "soils", "grid": "dem merc", "cellSize": 1}), json!({"layer": "soils", "grid": "Countries"}), json!({"layer": "soils", "cellSize": -1}), json!({"layer": "soils", "cellSize": 1e-9}), json!({"layer": "soils", "cellSize": 1, "extent": 5}), json!({"layer": "soils", "cellSize": 1, "path": "out.png"}), // No cell centre falls in any polygon. json!({"layer": "soils", "cellSize": 1, "extent": [-30.0, -30.0, -29.0, -29.0]}), ]; let (layers, tables) = (s.map().layers.len(), s.project.tables.len()); for p in bad { assert!(s.run("raster.rasterize", &p).is_err(), "{p}"); } assert_eq!((s.map().layers.len(), s.project.tables.len()), (layers, tables), "nothing added by a refused run"); let e = s.run("raster.rasterize", &json!({"layer": "soils"})).unwrap_err(); assert!(e.contains("cellSize is required"), "{e}"); let e = s.run("raster.rasterize", &json!({"layer": "soils", "nodata": 1, "cellSize": 1})).unwrap_err(); assert!(e.contains("no-data value"), "{e}"); } /// A whole number past what a pixel holds is refused with advice that applies: a numeric field can /// be given as text instead, one `value` can't. #[test] fn a_whole_number_past_a_pixel_is_refused_with_advice_that_applies() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let mut s = world(); let ring = vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 1.0), Coord::new(0.0, 0.0)]; let mut ids = Dataset::new("ids", GeometryType::Polygon, vec![Field::new("code", FieldType::Integer)]); ids.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![V::Int(20_000_001)]); crate::cmd::layer::add_dataset(&mut s, ids); let e = s.run("raster.rasterize", &json!({"layer": "ids", "value": 20_000_000, "cellSize": 1})).unwrap_err(); assert_eq!(e, "ids: value 20000000 is more than a raster's pixels hold exactly (16777216)"); let e = s.run("raster.rasterize", &json!({"layer": "ids", "field": "code", "cellSize": 1})).unwrap_err(); assert_eq!(e, "ids: code 20000001 is more than a raster's pixels hold exactly (16777216); a text field gives codes instead"); } /// A text field with more codes than the result lists: the first 256 there, all of them in the /// table, `classCount` saying how many, and too many to draw with unique values. #[test] fn a_text_fields_codes_past_those_listed_are_all_in_its_table() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let mut s = world(); let mut many = Dataset::new("many", GeometryType::Polygon, vec![Field::new("unit", FieldType::Text)]); for i in 0..257 { let (x0, x1) = (f64::from(i) * 0.1, f64::from(i + 1) * 0.1); let ring = vec![Coord::new(x0, 0.0), Coord::new(x1, 0.0), Coord::new(x1, 0.1), Coord::new(x0, 0.1), Coord::new(x0, 0.0)]; many.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![V::Text(format!("u{i:03}"))]); } crate::cmd::layer::add_dataset(&mut s, many); let v = s.run("raster.rasterize", &json!({"layer": "many", "field": "unit", "cellSize": 0.1})).unwrap(); assert_eq!( (v["classCount"].as_u64(), v["classes"].as_array().map(Vec::len), v["dataType"].as_str()), (Some(257), Some(256), Some("UInt16")) ); assert_eq!(v["classes"][255], json!({"code": 256, "value": "u255"})); let name = v["classTable"].as_str().unwrap(); let table = s.project.tables.iter().find(|t| t.name == name).expect("the codes' table"); assert_eq!((table.features.len(), table.value(256, 0), table.value(256, 1)), (257, &V::Int(257), &V::Text("u256".into()))); assert!(!matches!( s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().style.renderer, rustgis_map::RasterRenderer::UniqueValues { .. } )); } /// The codes' table doesn't take the place of a table of the user's with its name. #[test] fn a_text_fields_codes_leave_a_table_of_that_name_alone() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let mut s = world(); let mut soils = Dataset::new("soils", GeometryType::Polygon, vec![Field::new("unit", FieldType::Text)]); let ring = vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 1.0), Coord::new(0.0, 0.0)]; soils.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![V::Text("clay".into())]); crate::cmd::layer::add_dataset(&mut s, soils); let v = s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "cellSize": 1, "name": "free"})).unwrap(); assert_eq!(v["classTable"], "free classes", "no table has the name"); let mut mine = Dataset::new("units classes", GeometryType::None, vec![Field::new("note", FieldType::Text)]); mine.push_next(None, vec![V::Text("mine".into())]); let id = s.project.add_table(mine); let v = s.run("raster.rasterize", &json!({"layer": "soils", "field": "unit", "cellSize": 1, "name": "units"})).unwrap(); assert_eq!(s.project.table(id).expect("the user's table").value(0, 0), &V::Text("mine".into())); assert_eq!(v["classTable"], "units classes (2)"); let table = s.project.tables.iter().find(|t| t.name == "units classes (2)").expect("the codes' table"); assert_eq!(table.value(0, 1), &V::Text("clay".into())); } /// Georeferencing, projecting and rasterizing need a plugin's `files` only when given a `path`: a /// null one is no path, and writes no file. A place named another way still needs it. #[test] fn plugin_rasters_with_a_null_path_need_no_files() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let mut s = world(); let gt = GeoTransform { origin_x: 0.0, origin_y: 1.0, pixel_width: 0.1, pixel_height: -0.1 }; let r = Raster::new("dem", 10, 10, vec![vec![1.0; 100]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); crate::cmd::raster::add_raster(&mut s, "dem", rustgis_map::RasterLayer::from_bytes(r, "dem.tif", bytes)).unwrap(); let burn = json!({"layer": "Countries", "cellSize": 10, "name": "burned"}); let project = json!({"layer": "dem", "crs": 3857, "cellSize": 50_000, "name": "projected"}); let georeference = json!({"layer": "dem", "points": [[0, 0, 1, 1], [9, 0, 2, 1], [0, 9, 1, 2]], "transform": "affine", "name": "warped"}); plugin("eng-null-path", &["edit"]); for (op, params) in [("raster.rasterize", burn), ("raster.project", project), ("raster.georeference", georeference)] { let with = |key: &str, v: Value| { let mut p = params.clone(); p[key] = v; p }; forward(&mut s, "eng-null-path", op, with("path", Value::Null)).unwrap(); let name = params["name"].as_str().unwrap(); assert!(s.map().layer_by_name(name).is_some_and(|l| l.is_raster()), "{op}"); for refused in [with("path", json!("out.tif")), with("name", json!("../out"))] { let e = forward(&mut s, "eng-null-path", op, refused.clone()).unwrap_err(); assert!(e.contains("'files' permission"), "{refused}: {e}"); } } rustgis_plugins::unload("eng-null-path"); } /// The Analysis pane's single-pass raster tools made on another thread (#1291 step 3): each raster /// handed over (`ran`) is the one its command makes here, bit for bit and with what the command /// says of it besides, and a cancelled run is an error saying so. #[cfg(not(target_arch = "wasm32"))] #[test] fn raster_tools_run_on_another_thread() { use crate::cmd::workflows::{raster_land, raster_start, runs_in_background}; use rustgis_analysis::control::Control; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); s.run("raster.function", &json!({"layer": dem, "function": "bandMath", "expression": "b1 + 10", "name": "dem_later"})).unwrap(); s.run("layer.template", &json!({"template": "polygons", "name": "Training"})).unwrap(); for (name, x) in [("low", 10.002), ("high", 10.018)] { s.run("edit.addFeature", &json!({"layer": "Training", "geometry": {"type": "Polygon", "coordinates": [[[x, 45.018], [x + 0.004, 45.018], [x + 0.004, 45.022], [x, 45.022], [x, 45.018]]]}, "attributes": {"name": name}})).unwrap(); } s.run("imagery.workspace", &json!({"kind": "ortho", "name": "Valley", "images": [dem, "dem_later"], "cellSize": 0.002})).unwrap(); let pixels = |s: &Session, v: &Value| -> Vec { let l = s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap(); l.raster.as_ref().unwrap().data.as_ref().unwrap().bands.iter().flat_map(|b| b.data.iter().map(|x| x.to_bits())).collect() }; let ranges = json!([[f64::MIN, 300.0, 1], [300.0, f64::MAX, 10]]); for (command, p) in [ ("raster.function", json!({"layer": dem, "function": "hillshade"})), ("raster.function", json!({"layer": dem, "function": "resample", "target": "dem_later"})), ("raster.functionChain", json!({"layer": dem, "steps": [{"function": "slope"}, {"function": "threshold", "value": 5}]})), ( "analysis.suitability", json!({"criteria": [{"layer": dem, "ranges": ranges, "weight": 2}, {"layer": "dem_later", "ranges": ranges}]}), ), ("raster.change", json!({"before": dem, "after": "dem_later", "method": "percent"})), ("raster.classifySupervised", json!({"layer": dem, "training": "Training", "classField": "name"})), ("raster.index", json!({"layer": dem, "index": "ndvi", "nir": 1, "red": 1})), ("raster.process", json!({"layer": dem, "operation": "clip", "polygon": "Training"})), ("raster.process", json!({"layer": dem, "operation": "clip", "extent": [10.005, 45.005, 10.02, 45.02]})), ("raster.process", json!({"layer": dem, "operation": "resample", "factor": 4})), ("imagery.mosaic", json!({"name": "Valley"})), ] { assert!(runs_in_background(command).is_some(), "{command}"); let here = s.run(command, &p).unwrap(); let run = raster_start(&mut s, command, &p).unwrap(); let id = run.id; let made = std::thread::spawn(move || run.run(&Control::new())).join().unwrap().unwrap(); assert_eq!(raster_land(&mut s, id, made), Ok(true)); let mut with = p.clone(); with["ran"] = json!(id); let there = s.run(command, &with).unwrap(); assert_eq!(pixels(&s, &here), pixels(&s, &there), "{command} {p}"); let besides = |v: &Value| { v.as_object().map(|o| o.iter().filter(|(k, _)| *k != "layer").map(|(k, v)| (k.clone(), v.clone())).collect::>()) }; assert_eq!(besides(&here), besides(&there), "{command} {p}"); // The answer is the added layer's, whatever else the command says. assert!(there["width"].is_u64() && there["bands"].is_u64(), "{command}: {there}"); let run = raster_start(&mut s, command, &p).unwrap(); let control = Control::new(); control.cancel(); let e = run.run(&control).err().unwrap(); assert!(e.contains("was cancelled"), "{command}: {e}"); } // Their inputs are read when they start: a missing one is refused then. assert!(raster_start(&mut s, "raster.change", &json!({"before": dem, "after": "nowhere"})).err().unwrap().contains("nowhere")); assert!(raster_start(&mut s, "imagery.mosaic", &json!({"name": "Hill"})).err().unwrap().contains("Hill")); assert!(raster_start(&mut s, "raster.change", &json!({"before": dem, "after": "dem_later", "band": 2})).is_err()); let none = json!({"layer": dem, "training": "Training", "classField": "name", "bands": []}); assert!(raster_start(&mut s, "raster.classifySupervised", &none).err().unwrap().contains("at least one band")); // Pixels edited by hand stay immediate. assert!(runs_in_background("raster.editPixels").is_none()); } /// Export Features and Export Raster written on another thread, as the app writes them (#1291 /// step 3): the file, the answer and what the export says are those of one written here, nothing /// is said until it lands, and one cancelled before it starts writes nothing (a cancel in the /// middle of a write is the data crate's test). #[cfg(not(target_arch = "wasm32"))] #[test] fn exports_run_on_another_thread() { use crate::cmd::exports::{export_land, export_start, runs_in_background}; // A path as it appears in JSON, as `PATH`: on Windows its backslashes are escaped there. let unpathed_json = |v: &Value, path: &str| { let escaped = json!(path).to_string(); v.to_string().replace(escaped.trim_matches('"'), "PATH") }; use rustgis_analysis::control::Control; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); let dir = std::env::temp_dir().join(format!("rustgis-exports-thread-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let at = |name: &str| dir.join(name).display().to_string(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "map"})).unwrap(); for (command, file, p) in [ ("share.exportMap", "map.png", json!({"width": 64, "height": 48})), ("layout.export", "layout.png", json!({"dpi": 48})), ("layout.export", "layout.pdf", json!({"dpi": 48})), ("layout.export", "layout.svg", json!({"dpi": 48})), ("share.exportLayer", "countries.geojson", json!({"layer": "Countries"})), ("share.exportLayer", "countries.shp", json!({"layer": "Countries"})), ("share.exportLayer", "countries.gpkg", json!({"layer": "Countries", "crs": "EPSG:3857"})), ("share.exportRaster", "dem.tif", json!({"layer": dem})), ("share.exportRaster", "dem_cog.tif", json!({"layer": dem, "cog": true})), ] { assert!(runs_in_background(command).is_some(), "{command}"); let (here, there, cancelled) = (at(&format!("here_{file}")), at(&format!("there_{file}")), at(&format!("cancelled_{file}"))); let with = |path: &str| { let mut q = p.clone(); q["path"] = json!(path); q }; let said = |s: &Session, mark: u64, path: &str| -> Vec { s.notices_since(mark).iter().map(|n| n.text.replace(path, "PATH")).collect() }; let mark = s.notice_mark(); let answer_here = s.run(command, &with(&here)).unwrap(); let said_here = said(&s, mark, &here); let logged = |s: &Session| s.log.entries().last().map(|e| (e.command.clone(), e.params.clone(), e.ok)); let logged_here = logged(&s); assert!(logged_here.as_ref().is_some_and(|(c, ..)| c == command), "{command}: logged where it's called"); let run = export_start(&mut s, command, &with(&there)).unwrap(); let (mark, started) = (s.notice_mark(), run.started()); let wrote = std::thread::spawn(move || run.run(&Control::new())).join().unwrap(); assert!(s.notices_since(mark).is_empty(), "{command}: nothing is said before it lands"); let answer_there = export_land(&mut s, started, wrote).unwrap(); // The session's log has it as it has the export run here. let there_params = logged(&s).map(|(c, p, ok)| (c, unpathed_json(&p, &there), ok)); assert_eq!(there_params, logged_here.map(|(c, p, ok)| (c, unpathed_json(&p, &here), ok)), "{command} {file}"); let filed = s.diagnostics.records().last().map(|r| r.source.clone()); assert_eq!(filed.as_deref(), Some(command), "the history files what it said under the command"); assert_eq!(said(&s, mark, &there), said_here, "{command} {file}"); // The path as it appears in JSON: on Windows its backslashes are escaped there. let unpathed = |v: &Value, path: &str| { let escaped = json!(path).to_string(); v.to_string().replace(escaped.trim_matches('"'), "PATH") }; assert_eq!(unpathed(&answer_there, &there), unpathed(&answer_here, &here), "{command} {file}"); // A GeoPackage holds the time it was written: its features are compared instead. if file.ends_with(".gpkg") { let read = |path: &str| rustgis_data::io::read_path(std::path::Path::new(path)).unwrap().0.features.len(); assert_eq!(read(&there), read(&here), "{file}"); } else { assert_eq!(std::fs::read(&there).unwrap(), std::fs::read(&here).unwrap(), "{file}"); } let run = export_start(&mut s, command, &with(&cancelled)).unwrap(); let control = Control::new(); control.cancel(); assert!(run.run(&control).err().unwrap().contains("was cancelled"), "{command}"); assert!(!std::path::Path::new(&cancelled).exists(), "{file}: a cancelled export writes nothing"); } // Their inputs are read when they start: a missing layer or a bad system is refused then. assert!(export_start(&mut s, "share.exportLayer", &json!({"layer": "nowhere", "path": at("x.geojson")})).is_err()); assert!(export_start(&mut s, "share.exportLayer", &json!({"layer": "Countries", "path": at("x.gpkg"), "crs": "EPSG:nope"})).is_err()); assert!(export_start(&mut s, "share.exportRaster", &json!({"layer": dem, "path": at("x.tif"), "band": 99})).is_err()); assert!(export_start(&mut s, "layout.export", &json!({"path": at("x.jpg")})).is_err(), "a format it can't write"); // A true/false value it can't read is said when it starts, under the command (#1600); a clean // start says nothing. let unread = |s: &Session, mark: u64| s.notices_since(mark).iter().filter(|n| n.text.contains("allPages = \"typo\"")).count(); let mark = s.notice_mark(); export_start(&mut s, "layout.export", &json!({"path": at("pages.pdf"), "dpi": 48, "allPages": "yes"})).unwrap(); assert!(s.notices_since(mark).is_empty(), "{:?}", s.notices_since(mark)); export_start(&mut s, "layout.export", &json!({"path": at("pages.pdf"), "dpi": 48, "allPages": "typo"})).unwrap(); assert_eq!(unread(&s, mark), 1, "{:?}", s.notices_since(mark)); assert_eq!(s.diagnostics.records().last().map(|r| r.source.clone()).as_deref(), Some("layout.export")); // Map and layout exports draw a copy of what was there when they started: moving the map or // removing the layout meanwhile changes nothing in the file. let (here, there) = (at("still_here.png"), at("still_there.png")); s.run("share.exportMap", &json!({"path": here, "width": 64, "height": 48})).unwrap(); let run = export_start(&mut s, "share.exportMap", &json!({"path": there, "width": 64, "height": 48})).unwrap(); s.map_mut().camera.res *= 4.0; run.run(&Control::new()).unwrap(); assert_eq!(std::fs::read(&there).unwrap(), std::fs::read(&here).unwrap()); let (here, there) = (at("still_here.svg"), at("still_there.svg")); s.map_mut().camera.res /= 4.0; s.run("layout.export", &json!({"path": here, "dpi": 48})).unwrap(); let run = export_start(&mut s, "layout.export", &json!({"path": there, "dpi": 48})).unwrap(); let layouts = std::mem::take(&mut s.project.layouts); run.run(&Control::new()).unwrap(); assert_eq!(std::fs::read(&there).unwrap(), std::fs::read(&here).unwrap()); s.project.layouts = layouts; // A layout export that fails says why when it lands, as it does here. let nowhere = dir.join("no").join("such.png").display().to_string(); let here = s.run("layout.export", &json!({"path": nowhere, "dpi": 48})).unwrap_err(); let run = export_start(&mut s, "layout.export", &json!({"path": nowhere, "dpi": 48})).unwrap(); let started = run.started(); let mark = s.notice_mark(); assert_eq!(export_land(&mut s, started, run.run(&Control::new())).unwrap_err(), here); let errors: Vec<_> = s.notices_since(mark).iter().filter(|n| n.error).map(|n| n.text.clone()).collect(); assert_eq!(errors, std::slice::from_ref(&here), "its failure is said once"); let filed = s.diagnostics.records().last().map(|r| (r.source.clone(), r.severity)); assert_eq!(filed, Some(("layout.export".to_string(), rustgis_data::diagnostics::Severity::Error)), "under the command"); assert!(s.log.entries().last().is_some_and(|e| e.command == "layout.export" && !e.ok), "a failed one is logged as failed"); // A streamed layer's source that failed on the copy is marked on its live layer, unless that // was opened from somewhere else meanwhile. let li = s.map().layers.iter().position(|l| l.name == "Countries").unwrap(); let mut src = rustgis_map::source::SourceLayer::default(); src.path = Some("gone.fgb".into()); s.map_mut().layers[li].source = Some(src.clone()); let mut copy = s.map().detached(); let mut failed = src.clone(); failed.error = Some("gone.fgb: not found".into()); copy.layers[li].source = Some(failed); let land = |s: &mut Session, copy: &rustgis_map::Map| { let mut wrote = crate::cmd::exports::drawn(std::slice::from_ref(copy), Vec::new(), Ok(json!({})), at("drawn.png")).unwrap(); let started = export_start(s, "share.exportMap", &json!({"path": at("drawn.png")})).unwrap().started(); // This fixture supplies a drawn result directly, using the actual admitted run's owner. wrote.owner = Some(started.owner.clone()); export_land(s, started, Ok(wrote)).unwrap(); s.map().layers[li].source.as_ref().and_then(|src| src.error.clone()) }; assert_eq!(land(&mut s, ©).as_deref(), Some("gone.fgb: not found")); s.map_mut().layers[li].source.as_mut().unwrap().error = None; s.map_mut().layers[li].source.as_mut().unwrap().path = Some("found.fgb".into()); let mark = s.notice_mark(); assert_eq!(land(&mut s, ©), None, "a source opened afresh keeps its own state"); let said = s.notices_since(mark).iter().map(|n| n.text.clone()).collect::>().join("\n"); assert!(said.contains("Countries isn't in the file") && said.contains("gone.fgb: not found"), "and the file's gap is said: {said}"); // Opened afresh and failing again: still said, as the file lacks the layer either way. s.map_mut().layers[li].source.as_mut().unwrap().error = Some("found.fgb: not found".into()); let mark = s.notice_mark(); land(&mut s, ©); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("Countries isn't in the file"))); // Several left out: one notice, with a count. let mut two = copy.clone(); let other = two.layers.iter().position(|l| l.name != "Countries").unwrap(); let mut gone = rustgis_map::source::SourceLayer::default(); gone.path = Some("gone.fgb".into()); gone.error = Some("gone.fgb: not found".into()); two.layers[other].source = Some(gone); let mark = s.notice_mark(); land(&mut s, &two); let missing: Vec<_> = s.notices_since(mark).iter().filter(|n| n.text.contains("in the file")).map(|n| n.text.clone()).collect(); assert!(missing.len() == 1 && missing[0].contains("2 layers aren't in the file"), "{missing:?}"); s.map_mut().layers[li].source = None; // One that lands (or is refused) after another project opened is logged under the project it // started in. let (name, opened) = (s.project.name.clone(), s.opened); let run = export_start(&mut s, "share.exportMap", &json!({"path": at("before_new.png"), "width": 64, "height": 48})).unwrap(); let started = run.started(); let wrote = run.run(&Control::new()); s.run("project.new", &json!({"name": "Opened meanwhile"})).unwrap(); let _ = export_land(&mut s, started, wrote); let e = s.log.entries().last().cloned().unwrap(); assert_eq!((e.command.as_str(), e.project.as_str(), e.opened), ("share.exportMap", name.as_str(), opened), "{e:?}"); let _ = std::fs::remove_dir_all(&dir); } /// k-means classes and a deep learning classification made on another thread, as the Analysis pane /// runs them (#1291 step 3): the classes handed over (`ran`) are the ones the command makes here, a /// model trained on the thread is kept in the project only once its classes are added, and a raster /// handed to the other command isn't taken. #[cfg(not(target_arch = "wasm32"))] #[test] fn raster_classes_run_on_another_thread() { use crate::cmd::workflows::{raster_land, raster_start}; use rustgis_analysis::control::Control; let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); let training = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "field"}, "geometry": {"type": "Polygon", "coordinates": [[[10.025, 45.002], [10.038, 45.002], [10.038, 45.035], [10.025, 45.035], [10.025, 45.002]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "training.geojson", "text": training.to_string()})).unwrap(); let pixels = |s: &Session, v: &Value| -> Vec { let l = s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap(); l.raster.as_ref().unwrap().data.as_ref().unwrap().bands.iter().flat_map(|b| b.data.iter().map(|x| x.to_bits())).collect() }; let dl = json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 20, "model": "landcover"}); for (command, p) in [("raster.classify", json!({"layer": "scene", "classes": 3})), ("dl.classify", dl)] { let here = s.run(command, &p).unwrap(); s.project.dl_models.clear(); let run = raster_start(&mut s, command, &p).unwrap(); let id = run.id; let made = std::thread::spawn(move || run.run(&Control::new())).join().unwrap().unwrap(); assert_eq!(raster_land(&mut s, id, made), Ok(true)); // Trained on the thread, kept only when its classes are added. assert!(s.project.dl_models.is_empty(), "{command}"); let other = if command == "raster.classify" { "dl.classify" } else { "raster.classify" }; let mut with = p.clone(); with["ran"] = json!(id); assert!(s.run(other, &with).unwrap_err().contains("another command"), "{command}"); let there = s.run(command, &with).unwrap(); assert_eq!(pixels(&s, &here), pixels(&s, &there), "{command}"); let class_style = |v: &Value| { format!("{:?}", s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().style.renderer) }; assert_eq!(class_style(&here), class_style(&there), "{command}"); if command == "dl.classify" { assert_eq!((&here["classes"], &here["accuracy"], &here["model"]), (&there["classes"], &there["accuracy"], &there["model"])); assert_eq!(there["replaced"], false); assert_eq!(s.project.dl_models.iter().map(|m| m.name.as_str()).collect::>(), ["landcover"]); } } // A training cancelled once under way keeps no model, and says how far it came. s.project.dl_models.clear(); let p = json!({"layer": "scene", "training": "training", "classField": "class", "epochs": 500, "hidden": [64, 64]}); let run = raster_start(&mut s, "dl.classify", &p).unwrap(); let control = Control::new(); let ctl = control.clone(); let worker = std::thread::spawn(move || run.run(&ctl)); let end = std::time::Instant::now() + std::time::Duration::from_secs(60); while control.progress().is_none() { assert!(std::time::Instant::now() < end, "the run never counted its work"); std::thread::sleep(std::time::Duration::from_millis(1)); } control.cancel(); let e = worker.join().unwrap().err().unwrap_or_default(); assert!(e.contains("was cancelled") && !e.contains("before it started"), "{e}"); assert!(s.project.dl_models.is_empty()); // Sampling the training polygons is part of the work: a class whose polygons miss the raster // is said when the run ends, not when it starts. let lost = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "lost"}, "geometry": {"type": "Polygon", "coordinates": [[[-60.0, -10.0], [-59.0, -10.0], [-59.0, -9.0], [-60.0, -9.0], [-60.0, -10.0]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "lost.geojson", "text": lost.to_string()})).unwrap(); let run = raster_start(&mut s, "dl.classify", &json!({"layer": "scene", "training": "lost", "classField": "class"})).unwrap(); assert!(run.run(&Control::new()).err().is_some_and(|e| e.contains("'lost'"))); // A model given no name doesn't replace one kept while it trained: it's named when it is kept. s.project.dl_models.clear(); let p = json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 5}); let run = raster_start(&mut s, "dl.classify", &p).unwrap(); let id = run.id; assert_eq!(raster_land(&mut s, id, run.run(&Control::new()).unwrap()), Ok(true)); let trained = s.run("dl.train", &json!({"layer": "scene", "training": "training", "classField": "class", "epochs": 5})).unwrap(); assert_eq!(trained["model"], "scene_model"); let mut with = p.clone(); with["ran"] = json!(id); let r = s.run("dl.classify", &with).unwrap(); assert_eq!((&r["model"], &r["replaced"]), (&json!("scene_model 2"), &json!(false)), "{r}"); assert_eq!(s.project.dl_models.iter().map(|m| m.name.as_str()).collect::>(), ["scene_model", "scene_model 2"]); } /// A session with one layer "Mesh" read from GeoJSON features. fn mesh_session(features: Value) -> Session { let text = json!({"type": "FeatureCollection", "features": features}).to_string(); let (ds, _) = rustgis_data::io::read_geojson(&text, "Mesh").unwrap(); let mut s = Session::new(Project::default()); let id = s.map_mut().add_dataset(ds); s.active_layer = Some(id); s } fn mesh_feature(geometry: Value, h: f64) -> Value { json!({"type": "Feature", "geometry": geometry, "properties": {"h": h}}) } /// Every geometry kind the 3D export handles: a polygon with a hole, a clockwise polygon, a line /// and two points. fn mixed_mesh_session() -> Session { mesh_session(json!([ mesh_feature( json!({"type": "Polygon", "coordinates": [ [[0.0, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01], [0.0, 0.0]], [[0.002, 0.002], [0.002, 0.004], [0.004, 0.004], [0.004, 0.002], [0.002, 0.002]]]}), 30.0 ), mesh_feature( json!({"type": "Polygon", "coordinates": [[[0.02, 0.0], [0.02, 0.01], [0.03, 0.01], [0.03, 0.0], [0.02, 0.0]]]}), 10.0 ), mesh_feature(json!({"type": "LineString", "coordinates": [[0.0, 0.02], [0.01, 0.02]]}), 5.0), mesh_feature(json!({"type": "MultiPoint", "coordinates": [[0.03, 0.02], [0.04, 0.02]]}), 20.0), ])) } #[test] fn obj_export_is_unchanged() { let mut s = mixed_mesh_session(); let (name, bytes) = crate::cmd::items::item_bytes(&mut s, "obj", &json!({"heightField": "h"})).unwrap(); assert_eq!(name, "Mesh.obj"); // Written by the exporter before the mesh builder was shared with STL. let golden = concat!( "# Mesh exported by RustGIS\n", "o Mesh\n", "v 0.00 0.00 -0.00\n", "v 1113.19 0.00 -0.00\n", "v 1113.19 0.00 -1113.19\n", "v 0.00 0.00 -1113.19\n", "v 222.64 0.00 -222.64\n", "v 222.64 0.00 -445.28\n", "v 445.28 0.00 -445.28\n", "v 445.28 0.00 -222.64\n", "v 0.00 30.00 -0.00\n", "v 1113.19 30.00 -0.00\n", "v 1113.19 30.00 -1113.19\n", "v 0.00 30.00 -1113.19\n", "v 222.64 30.00 -222.64\n", "v 222.64 30.00 -445.28\n", "v 445.28 30.00 -445.28\n", "v 445.28 30.00 -222.64\n", "f 1 2 10 9\n", "f 2 3 11 10\n", "f 3 4 12 11\n", "f 4 1 9 12\n", "f 5 6 14 13\n", "f 6 7 15 14\n", "f 7 8 16 15\n", "f 8 5 13 16\n", "f 9 16 13\n", "f 5 8 1\n", "f 12 9 13\n", "f 5 1 4\n", "f 12 13 14\n", "f 6 5 4\n", "f 12 14 15\n", "f 7 6 4\n", "f 16 9 10\n", "f 2 1 8\n", "f 15 16 10\n", "f 2 8 7\n", "f 12 15 10\n", "f 2 7 4\n", "f 10 11 12\n", "f 4 3 2\n", "v 2226.39 0.00 -0.00\n", "v 2226.39 0.00 -1113.19\n", "v 3339.58 0.00 -1113.19\n", "v 3339.58 0.00 -0.00\n", "v 2226.39 10.00 -0.00\n", "v 2226.39 10.00 -1113.19\n", "v 3339.58 10.00 -1113.19\n", "v 3339.58 10.00 -0.00\n", "f 17 18 22 21\n", "f 18 19 23 22\n", "f 19 20 24 23\n", "f 20 17 21 24\n", "f 21 24 23\n", "f 19 20 17\n", "f 23 22 21\n", "f 17 18 19\n", "v 0.00 5.00 -2226.39\n", "v 1113.19 5.00 -2226.39\n", "l 25 26\n", "v 3338.58 0 -2227.39\n", "v 3340.58 0 -2227.39\n", "v 3340.58 0 -2225.39\n", "v 3338.58 0 -2225.39\n", "v 3338.58 20.00 -2227.39\n", "v 3340.58 20.00 -2227.39\n", "v 3340.58 20.00 -2225.39\n", "v 3338.58 20.00 -2225.39\n", "f 27 28 32 31\n", "f 28 29 33 32\n", "f 29 30 34 33\n", "f 30 27 31 34\n", "f 31 32 33 34\n", "f 30 29 28 27\n", "v 4451.78 0 -2227.39\n", "v 4453.78 0 -2227.39\n", "v 4453.78 0 -2225.39\n", "v 4451.78 0 -2225.39\n", "v 4451.78 20.00 -2227.39\n", "v 4453.78 20.00 -2227.39\n", "v 4453.78 20.00 -2225.39\n", "v 4451.78 20.00 -2225.39\n", "f 35 36 40 39\n", "f 36 37 41 40\n", "f 37 38 42 41\n", "f 38 35 39 42\n", "f 39 40 41 42\n", "f 38 37 36 35\n", ); assert_eq!(String::from_utf8(bytes).unwrap(), golden); } /// The triangles of a binary STL: (normal, vertices). fn stl_triangles(bytes: &[u8]) -> Vec<([f32; 3], [[f32; 3]; 3])> { let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize; assert_eq!(bytes.len(), 84 + 50 * count); let f = |at: usize| f32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()); (0..count) .map(|i| { let o = 84 + 50 * i; let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)]; assert_eq!(&bytes[o + 48..o + 50], &[0, 0]); (v(0), [v(1), v(2), v(3)]) }) .collect() } fn square(x0: f64, clockwise: bool) -> Value { let mut ring = vec![[x0, 0.0], [x0 + 0.01, 0.0], [x0 + 0.01, 0.01], [x0, 0.01], [x0, 0.0]]; if clockwise { ring.reverse(); } json!({"type": "Polygon", "coordinates": [ring]}) } #[test] fn stl_is_a_listed_item_kind() { assert!(crate::cmd::items::ITEMS.contains(&("stl", "stl", "STL 3D model"))); } #[test] fn stl_export_has_the_binary_layout_and_closed_outward_solids() { // One extruded square, wound either way: 4 walls x 2 + 2 caps x 2 triangles. for clockwise in [false, true] { let mut s = mesh_session(json!([mesh_feature(square(0.0, clockwise), 30.0)])); let (name, bytes) = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); assert_eq!(name, "Mesh.stl"); assert!(!bytes.starts_with(b"solid")); let tris = stl_triangles(&bytes); assert_eq!(tris.len(), 12); assert_eq!(bytes.len(), 84 + 50 * 12); // Centre of the solid: (556.6, 556.6, 15) in east, north, up metres. let centre = [1113.19_f32 / 2.0, 1113.19 / 2.0, 15.0]; // Z is up, and every vertex is in the all-positive octant. assert!(tris.iter().all(|(_, v)| v.iter().flatten().all(|x| *x >= 0.0)), "positive octant"); let mut area_sum = [0.0_f64; 3]; for (n, v) in &tris { let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); assert!((len - 1.0).abs() < 1e-4, "unit normal {n:?}"); // The stored normal agrees with the winding, and points away from the centre. let (a, b, c) = (v[0].map(f64::from), v[1].map(f64::from), v[2].map(f64::from)); let (u, w) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]); let cross = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; let dot: f64 = (0..3).map(|k| cross[k] * f64::from(n[k])).sum(); assert!(dot > 0.0, "normal follows the winding"); let mid: [f64; 3] = std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0 - f64::from(centre[k])); let out: f64 = (0..3).map(|k| mid[k] * f64::from(n[k])).sum(); assert!(out > 0.0, "normal points outward, clockwise={clockwise}: {n:?}"); for k in 0..3 { area_sum[k] += cross[k] / 2.0; } } // A closed surface's area vectors cancel. assert!(area_sum.iter().all(|x| x.abs() < 1.0), "closed solid: {area_sum:?}"); } } /// A point at the layer's corner becomes a cube reaching past it: the model still starts at zero. #[test] fn stl_point_cubes_stay_in_the_positive_octant() { let pts = [[0.0, 0.0], [0.01, 0.01]].map(|c| mesh_feature(json!({"type": "Point", "coordinates": c}), 30.0)); let mut s = mesh_session(json!(pts)); let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); let tris = stl_triangles(&bytes); assert_eq!(tris.len(), 24, "two cubes"); let low = |k: usize| tris.iter().flat_map(|(_, v)| v.iter().map(move |p| p[k])).fold(f32::INFINITY, f32::min); assert_eq!((low(0), low(1), low(2)), (0.0, 0.0, 0.0)); } #[test] fn stl_leaves_out_lines_and_skips_flat_triangles_and_says_so() { let mut s = mesh_session(json!([ mesh_feature(square(0.0, false), 0.0), mesh_feature(json!({"type": "LineString", "coordinates": [[0.0, 0.02], [0.01, 0.02]]}), 5.0), mesh_feature(json!({"type": "Point", "coordinates": [0.03, 0.02]}), 20.0), mesh_feature(json!({"type": "Point", "coordinates": [0.05, 0.02]}), 0.0), mesh_feature( json!({"type": "Polygon", "coordinates": [[[0.06, 0.0], [0.07, 0.0], [0.070_000_000_01, 0.0], [0.07, 0.01], [0.06, 0.01], [0.06, 0.0]]]}), 30.0 ), ])); let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); // The flat square and the flat point are left out, not written as two coincident caps; the // point of 20 m makes a cube of 12, and the square's 1 µm edge, one point in 32-bit floats, // gives zero-area triangles (4 of its 16) that are skipped. let tris = stl_triangles(&bytes); assert_eq!(tris.len(), 12 + 12); let said = |part: &str| s.notices.iter().any(|n| n.text.contains(part)); assert!(said("Left out 2 polygon(s) and point(s) of zero height")); assert!(said("left out 1 polyline")); assert!(said("zero-area")); assert!(said("no units")); // A layer of squares gives neither the line nor the zero-area notices. let mut clean = mesh_session(json!([mesh_feature(square(0.0, false), 30.0)])); crate::cmd::items::item_bytes(&mut clean, "stl", &json!({"heightField": "h"})).unwrap(); assert!(!clean.notices.iter().any(|n| n.text.contains("polyline") || n.text.contains("zero-area") || n.text.contains("zero height"))); // OBJ keeps its polylines and says nothing about STL. let (_, obj) = crate::cmd::items::item_bytes(&mut s, "obj", &json!({"heightField": "h"})).unwrap(); assert!(String::from_utf8(obj).unwrap().contains("\nl ")); } #[test] fn stl_export_command_writes_by_extension_and_guards_size() { let dir = std::env::temp_dir().join(format!("rustgis-stl-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("m.stl").to_string_lossy().into_owned(); let mut s = mixed_mesh_session(); let r = s.run("share.export3d", &json!({"path": path, "heightField": "h"})).unwrap(); assert_eq!(r["linesDropped"], 1); let written = std::fs::read(&path).unwrap(); assert_eq!(written.len() as u64, r["bytes"].as_u64().unwrap()); assert_eq!(stl_triangles(&written).len() as u64, r["triangles"].as_u64().unwrap()); let _ = std::fs::remove_dir_all(&dir); // Past the cap it is an error, not a panic or a truncated file. let e = crate::cmd::items::stl_with_cap(&s, &json!({"heightField": "h"}), 5).unwrap_err(); assert!(e.contains("too large"), "{e}"); } #[test] fn stl_solids_below_their_base_still_face_outward() { // A negative field extrudes downward: the square and the point's cube must stay outward. for geometry in [square(0.0, false), square(0.0, true), json!({"type": "Point", "coordinates": [0.0, 0.0]})] { let mut s = mesh_session(json!([mesh_feature(geometry, -30.0)])); let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); let tris = stl_triangles(&bytes); // Both shapes are convex, so the mean of the corners is inside. let corners: Vec<[f64; 3]> = tris.iter().flat_map(|(_, v)| v.iter().map(|p| p.map(f64::from))).collect(); let centre: [f64; 3] = std::array::from_fn(|k| corners.iter().map(|p| p[k]).sum::() / corners.len() as f64); assert!(centre[2] < 0.0); for (n, v) in &tris { let (a, b, c) = (v[0].map(f64::from), v[1].map(f64::from), v[2].map(f64::from)); let (u, w) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]); let cross = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; let winding: f64 = (0..3).map(|k| cross[k] * f64::from(n[k])).sum(); assert!(winding > 0.0, "normal follows the winding"); let out: f64 = (0..3).map(|k| ((a[k] + b[k] + c[k]) / 3.0 - centre[k]) * f64::from(n[k])).sum(); assert!(out > 0.0, "normal points outward: {n:?}"); } } } #[test] fn stl_refuses_coordinates_beyond_f32_and_all_flat_layers() { let mut s = mesh_session(json!([mesh_feature(square(0.0, false), 1e39)])); let e = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap_err(); assert!(e.contains("too large for a 32-bit"), "{e}"); // A layer whose triangles are all flat says so, not "no polygons". let mut flat = mesh_session(json!([mesh_feature( json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [0.01, 0.0], [0.02, 0.0], [0.0, 0.0]]]}), 0.0 )])); let e = crate::cmd::items::item_bytes(&mut flat, "stl", &json!({"heightField": "h"})).unwrap_err(); assert!(e.contains("zero height"), "{e}"); // So does one of zero-height solids, which STL can't hold as flat sheets; OBJ still writes them. let mut sheets = mesh_session(json!([mesh_feature(square(0.0, false), 0.0)])); let e = crate::cmd::items::item_bytes(&mut sheets, "stl", &json!({"heightField": "h"})).unwrap_err(); assert!(e.contains("zero height"), "{e}"); assert!(crate::cmd::items::item_bytes(&mut sheets, "obj", &json!({"heightField": "h"})).is_ok()); } #[test] fn stl_of_lines_only_says_why_there_is_nothing() { let mut s = mesh_session(json!([mesh_feature(json!({"type": "LineString", "coordinates": [[0.0, 0.0], [0.01, 0.0]]}), 5.0)])); let e = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap_err(); assert!(e.contains("not lines"), "{e}"); assert!(crate::cmd::items::item_bytes(&mut s, "obj", &json!({"heightField": "h"})).is_ok()); } #[test] fn stl_fans_a_cap_past_the_triangulator_limit_and_says_so() { // A circle of 5001 vertices is past the triangulator's limit, so its cap is fanned. It has a hole too. let mut ring: Vec<[f64; 2]> = (0..5001) .map(|i| { let a = i as f64 * std::f64::consts::TAU / 5001.0; [0.01 * a.cos(), 0.01 * a.sin()] }) .collect(); let first = ring[0]; ring.push(first); let hole = json!([[0.001, -0.001], [0.002, -0.001], [0.002, 0.001], [0.001, 0.001], [0.001, -0.001]]); let mut s = mesh_session(json!([mesh_feature(json!({"type": "Polygon", "coordinates": [ring, hole]}), 30.0)])); let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); assert!(!stl_triangles(&bytes).is_empty()); let said = |part: &str| s.notices.iter().any(|n| n.text.contains(part)); assert!(said("1 polygon cap(s) couldn't be triangulated")); assert!(said("holes may be filled")); } /// A polygon the triangulator can only finish as a fan: a hole sharing the outer ring's whole left /// side (the geom crate's `a_ring_left_with_no_ear_is_finished_as_a_fan_and_says_so`), in degrees. fn fan_polygon() -> Value { json!({"type": "Polygon", "coordinates": [ [[0.0, 0.0], [0.04, 0.0], [0.04, 0.04], [0.0, 0.04], [0.0, 0.0]], [[0.0, 0.0], [0.03, 0.02], [0.0, 0.04], [0.0, 0.0]]]}) } #[test] fn three_d_exports_count_internal_fans_with_the_others_and_clean_caps_say_nothing() { for kind in ["obj", "stl"] { // A cap the triangulator can only finish as a fan is reported like one it gave up on. let mut s = mesh_session(json!([mesh_feature(fan_polygon(), 30.0), mesh_feature(square(0.0, false), 30.0)])); crate::cmd::items::item_bytes(&mut s, kind, &json!({"heightField": "h"})).unwrap(); let said = |part: &str| s.notices.iter().any(|n| n.text.contains(part)); assert!(said("1 polygon cap(s) couldn't be triangulated"), "{kind}: {:?}", s.notices); // Clean caps give no such notice. let mut clean = mesh_session(json!([mesh_feature(square(0.0, false), 30.0)])); crate::cmd::items::item_bytes(&mut clean, kind, &json!({"heightField": "h"})).unwrap(); assert!(!clean.notices.iter().any(|n| n.text.contains("couldn't be triangulated")), "{kind}: {:?}", clean.notices); } } #[test] fn web_scene_fans_caps_it_cannot_triangulate_and_says_so() { let dir = std::env::temp_dir().join(format!("rustgis-scene-fans-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("scene.gltf").display().to_string(); // One cap past the triangulator's limit (5001 vertices) and one it finishes as a fan: two caps, one notice. let big: Vec<[f64; 2]> = (0..5001) .map(|i| { let a = i as f64 * std::f64::consts::TAU / 5001.0; [0.01 * a.cos(), 0.01 * a.sin()] }) .collect(); let mut s = mesh_session(json!([ mesh_feature(json!({"type": "Polygon", "coordinates": [big]}), 30.0), mesh_feature(fan_polygon(), 30.0), mesh_feature(square(0.0, false), 30.0), ])); s.run("share.webScene", &json!({"path": path})).unwrap(); let said: Vec<&str> = s.notices.iter().map(|n| n.text.as_str()).filter(|t| t.contains("couldn't be triangulated")).collect(); assert_eq!(said.len(), 1, "{:?}", s.notices); assert!(said[0].starts_with("2 polygon cap(s) couldn't be triangulated"), "{said:?}"); // A clean scene says nothing about caps. let mut clean = mesh_session(json!([mesh_feature(square(0.0, false), 30.0)])); clean.run("share.webScene", &json!({"path": path})).unwrap(); assert!(!clean.notices.iter().any(|n| n.text.contains("couldn't be triangulated")), "{:?}", clean.notices); // On its own (with a hole it can't use), the oversized polygon is in the scene as a fan over // its outer ring: 4999 triangles, and only the ring's 5001 positions (the layer isn't extruded, // so the cap's are all there are), all on the circle, none of the hole's. let hole = [[0.001, 0.001], [0.002, 0.001], [0.0015, 0.002], [0.001, 0.001]]; let mut holed = mesh_session(json!([mesh_feature(json!({"type": "Polygon", "coordinates": [big, hole]}), 30.0)])); holed.run("share.webScene", &json!({"path": path})).unwrap(); let said: Vec<&str> = holed.notices.iter().map(|n| n.text.as_str()).filter(|t| t.contains("couldn't be triangulated")).collect(); assert!(matches!(said[..], [t] if t.starts_with("1 polygon cap(s) couldn't be triangulated")), "{:?}", holed.notices); let g: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); let prim = &g["meshes"][0]["primitives"][0]; let accessor = |i: &Value| &g["accessors"][i.as_u64().unwrap() as usize]; assert_eq!(accessor(&prim["indices"])["count"], 4999 * 3); let at = accessor(&prim["attributes"]["POSITION"]); assert_eq!(at["count"], 5001); let uri = g["buffers"][0]["uri"].as_str().unwrap(); let bytes = { use base64::Engine; base64::engine::general_purpose::STANDARD.decode(uri.split_once(",").unwrap().1).unwrap() }; // The indices are the fan from the ring's first vertex, wound to face up (+y) for this // counter-clockwise ring: (0, i, i + 1). let ia = accessor(&prim["indices"]); let iv = &g["bufferViews"][ia["bufferView"].as_u64().unwrap() as usize]; let istart = (iv["byteOffset"].as_u64().unwrap_or(0) + ia["byteOffset"].as_u64().unwrap_or(0)) as usize; let indices: Vec = bytes[istart..istart + 4999 * 3 * 4].chunks(4).map(|b| u32::from_le_bytes(b.try_into().unwrap())).collect(); let fan: Vec = (1..5000u32).flat_map(|i| [0, i, i + 1]).collect(); assert_eq!(indices, fan); let view = &g["bufferViews"][at["bufferView"].as_u64().unwrap() as usize]; let start = (view["byteOffset"].as_u64().unwrap_or(0) + at["byteOffset"].as_u64().unwrap_or(0)) as usize; let xz: Vec<(f64, f64)> = bytes[start..start + 5001 * 12] .chunks(12) .map(|v| { let f = |o: usize| f64::from(f32::from_le_bytes(v[o..o + 4].try_into().unwrap())); (f(0), f(8)) }) .collect(); // Each position is its source vertex, in order: metres east (x) and south (z) of the map's centre. let c = holed.map().center_lonlat(); let (origin, k) = (rustgis_geom::lonlat_to_merc(c), c.y.to_radians().cos().abs().max(0.01)); for (i, (x, z)) in xz.iter().enumerate() { let q = rustgis_geom::lonlat_to_merc(rustgis_geom::Coord::new(big[i][0], big[i][1])); let (ex, ez) = ((q.x - origin.x) * k, -(q.y - origin.y) * k); assert!((x - ex).abs() < 0.125 && (z - ez).abs() < 0.125, "vertex {i}: ({x}, {z}), not ({ex}, {ez})"); } // And every triangle faces up: its normal, (b - a) × (c - a), has a positive y. for t in indices.chunks(3) { let [a, b, c] = [t[0], t[1], t[2]].map(|n| xz[n as usize]); let (u, v) = ((b.0 - a.0, b.1 - a.1), (c.0 - a.0, c.1 - a.1)); assert!(u.1 * v.0 - u.0 * v.1 > 0.0, "{t:?} faces down"); } // Features too far from the centre for 32-bit positions (a polygon, a line) are left out and // said, not written as infinity. let far = json!({"type": "Polygon", "coordinates": [[[1e40, 0.0], [1e40, 0.01], [1.01e40, 0.0], [1e40, 0.0]]]}); let far_line = json!({"type": "LineString", "coordinates": [[0.0, 0.0], [1e40, 0.0]]}); let mut s = mesh_session(json!([mesh_feature(far, 30.0), mesh_feature(far_line, 30.0), mesh_feature(square(0.0, false), 30.0)])); s.run("share.webScene", &json!({"path": path})).unwrap(); assert!( s.notices.iter().any(|n| n.text == "2 feature(s) left out of the scene: a coordinate is too far from the map's centre"), "{:?}", s.notices ); let text = std::fs::read_to_string(&path).unwrap(); assert!(serde_json::from_str::(&text).is_ok() && !text.contains("null"), "{text}"); // A far ring the scene wouldn't draw anyway (two vertices) isn't counted as left out for it. let flat_far = json!({"type": "Polygon", "coordinates": [[[1e40, 0.0], [0.0, 0.01], [1e40, 0.0]]]}); let mut s = mesh_session(json!([mesh_feature(flat_far, 30.0), mesh_feature(square(0.0, false), 30.0)])); s.run("share.webScene", &json!({"path": path})).unwrap(); assert!(!s.notices.iter().any(|n| n.text.contains("left out of the scene")), "{:?}", s.notices); let _ = std::fs::remove_dir_all(dir); } #[test] fn layer_meshes_report_single_vertex_lines_and_skip_heights_of_empty_features() { // A one-vertex line part is left out and said; a feature without geometry draws nothing, so // its unusable height isn't counted as defaulted. let mut s = mesh_session(json!([ mesh_feature(square(0.0, false), 30.0), mesh_feature(json!({"type": "LineString", "coordinates": [[0.0, 0.02]]}), 5.0), {"type": "Feature", "geometry": null, "properties": {"h": "tall"}}, ])); for kind in ["obj", "stl"] { s.notices.clear(); crate::cmd::items::item_bytes(&mut s, kind, &json!({"heightField": "h"})).unwrap(); let said = |part: &str| s.notices.iter().any(|n| n.text.contains(part)); assert!(said("Skipped 1 line part(s) with a single vertex"), "{kind}"); assert!(!said("default height"), "{kind}"); } // A layer whose only line has one vertex says why STL has nothing. let mut lines = mesh_session(json!([mesh_feature(json!({"type": "LineString", "coordinates": [[0.0, 0.0]]}), 5.0)])); let e = crate::cmd::items::item_bytes(&mut lines, "stl", &json!({"heightField": "h"})).unwrap_err(); assert!(e.contains("not lines"), "{e}"); } #[test] fn layer_meshes_say_what_they_leave_out_or_default() { // A hole of two vertices is dropped, and a height that isn't a number takes the default. let two_vertex_hole = json!({"type": "Polygon", "coordinates": [ [[0.0, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01], [0.0, 0.0]], [[0.002, 0.002], [0.003, 0.003], [0.002, 0.002]]]}); let mut s = mesh_session(json!([ mesh_feature(two_vertex_hole, 30.0), {"type": "Feature", "geometry": square(0.02, false), "properties": {"h": "tall"}}, ])); crate::cmd::items::item_bytes(&mut s, "stl", &json!({"heightField": "h"})).unwrap(); let said = |part: &str| s.notices.iter().any(|n| n.text.contains(part)); assert!(said("Dropped 1 hole(s)")); assert!(said("used the default height")); // Clean input says neither, for OBJ or STL. let mut clean = mesh_session(json!([mesh_feature(square(0.0, false), 30.0)])); crate::cmd::items::item_bytes(&mut clean, "obj", &json!({"heightField": "h"})).unwrap(); crate::cmd::items::item_bytes(&mut clean, "stl", &json!({"heightField": "h"})).unwrap(); assert!(!clean.notices.iter().any(|n| n.text.contains("hole") || n.text.contains("default height"))); } #[test] fn rgbi_mosaics_keep_nir_without_alpha_and_reject_mixed_band_roles() { isolate_plugins(); use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; use rustgis_map::raster::RasterLayer; let mut s = Session::new(Project::default()); let gt = GeoTransform { origin_x: 0.0, origin_y: 2.0, pixel_width: 1.0, pixel_height: -1.0 }; for (name, color, count) in [ ("nir", ColorHint::Rgb { alpha: false }, 4), ("nir2", ColorHint::Rgb { alpha: false }, 4), ("rgba", ColorHint::Rgb { alpha: true }, 4), ("rgb", ColorHint::Rgb { alpha: false }, 3), ("multispectral", ColorHint::Gray, 4), ] { let mut bands = vec![vec![200.0; 4], vec![100.0; 4], vec![50.0; 4], vec![0.0; 4]]; bands.truncate(count); let r = Raster::new(name, 2, 2, bands, gt, Crs::LonLat, None, color, "UInt8").unwrap(); let bytes = geotiff::write(&r, 0).unwrap(); s.map_mut().add_raster(name, RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)); } for (name, images, alpha) in [("RGBI", vec!["nir", "nir2"], false), ("RGBA", vec!["rgba"], true)] { s.run("imagery.workspace", &json!({"kind":"ortho", "name":name, "images":images, "cellSize":1})).unwrap(); let result = s.run("imagery.mosaic", &json!({"name":name})).unwrap(); assert!(result.get("messages").is_none(), "{result}"); let layer = s.map().layer(result["layer"].as_u64().unwrap() as u32).unwrap(); let rl = layer.raster.as_ref().unwrap(); let r = rl.data.as_ref().unwrap(); assert_eq!(r.band_count(), 4); assert_eq!(r.color, ColorHint::Rgb { alpha }); assert_eq!(r.value(3, 0, 0), Some(0.0)); // The embedded file must also keep NIR when a project is reopened. let back = geotiff::read(std::io::Cursor::new(rl.embedded.as_ref().unwrap().as_slice()), "saved").unwrap(); assert_eq!((back.band_count(), back.color), (4, ColorHint::Rgb { alpha })); } for (name, images, lost) in [ ("RGBI + RGB", vec!["nir", "rgb"], vec!["nir"]), ("two RGBI + RGB", vec!["rgb", "nir", "nir2"], vec!["nir", "nir2"]), ("RGBA + RGB", vec!["rgba", "rgb"], vec!["rgba"]), ] { s.run("imagery.workspace", &json!({"kind":"ortho", "name":name, "images":images, "cellSize":1})).unwrap(); let result = s.run("imagery.mosaic", &json!({"name":name})).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages.len(), 1, "{result}"); let message = messages[0].as_str().unwrap(); assert!(message.starts_with(&format!("{} image(s)", lost.len())), "{message}"); assert!(message.contains("3-band mosaic"), "{message}"); for image in lost { assert!(message.contains(&format!("'{image}': band(s) 4")), "{message}"); } assert!(!message.contains("'rgb':"), "{message}"); let layer = s.map().layer(result["layer"].as_u64().unwrap() as u32).unwrap(); let r = layer.raster.as_ref().unwrap().data.as_ref().unwrap(); assert_eq!(r.band_count(), 3); assert_eq!(r.color, ColorHint::Rgb { alpha: false }); } for (name, images) in [ ("mixed", vec!["nir", "rgba"]), ("mixed non-RGB", vec!["multispectral", "rgba"]), ("mixed non-RGB reversed", vec!["rgba", "multispectral"]), ] { s.run("imagery.workspace", &json!({"kind":"ortho", "name":name, "images":images, "cellSize":1})).unwrap(); let e = s.run("imagery.mosaic", &json!({"name":name})).unwrap_err().to_string(); assert!(e.contains("matching band roles"), "{e}"); // Mosaic Rasters refuses the same mix rather than giving one input's role to both. let e = s.run("raster.mosaic", &json!({"inputs": images, "name": format!("{name} raster")})).unwrap_err().to_string(); assert!(e.contains("matching band roles"), "{e}"); } // Matching roles still mosaic, keeping them. let v = s.run("raster.mosaic", &json!({"inputs": ["nir", "nir2"], "name": "nir raster"})).unwrap(); let r = s.map().layer(v["layer"].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); assert_eq!((r.band_count(), r.color.clone()), (4, ColorHint::Rgb { alpha: false })); } /// Add Data's and Load All's reads made before the command runs (the desktop app's, on a thread /// of their own) are the ones the command takes: it adds what they read, without reading again. #[cfg(not(target_arch = "wasm32"))] #[test] fn reads_made_ahead_are_taken_by_the_command() { if isolate_process_test("tests::reads_made_ahead_are_taken_by_the_command") { return; } use base64::Engine; use rustgis_analysis::control::Control; let dir = std::env::temp_dir().join(format!("rustgis-reads-ahead-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let points = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"n":1},"geometry":{"type":"Point","coordinates":[8,47]}},{"type":"Feature","properties":{"n":2},"geometry":{"type":"Point","coordinates":[9,46]}}]}"#; let files = [ ("points.geojson", points.as_bytes().to_vec()), ("places.csv", b"name,x,y\nA,8,47\nB,9,46\nC,10,45\n".to_vec()), ("places.xlsx", rustgis_data::sheets::xlsx(&[("Places", &[&["name", "longitude", "latitude"], &["A", "10", "20"]])])), ("utm.tif", base64::engine::general_purpose::STANDARD.decode(utm_tiff_b64()).unwrap()), ]; let mut s = Session::new(Project::default()); for (name, bytes) in files { let path = dir.join(name); std::fs::write(&path, bytes).unwrap(); let p = json!({"path": path.display().to_string(), "zoom": false}); let ahead = crate::cmd::reads::read_ahead(&mut s, "layer.addData", &p).unwrap(); assert!(ahead.reads(), "{name} is read ahead"); let read = ahead.run(&Control::new()).unwrap(); // The file gone: what the command adds can only come from the read. std::fs::remove_file(&path).unwrap(); let r = crate::cmd::reads::read_land(&mut s, read).unwrap_or_else(|e| panic!("{name}: {e}")); assert!(s.read_ahead.is_none(), "{name}: the read was taken"); assert!(r.get("layer").is_some() || r.get("layers").is_some(), "{name}: {r}"); } assert_eq!(s.map().layers.len(), 4, "{:?}", s.map().layers.iter().map(|l| &l.name).collect::>()); // A file read in place (and any the command reads differently) reads as it runs. let gpkg = json!({"path": dir.join("table.gpkg").display().to_string()}); assert!(!crate::cmd::reads::read_ahead(&mut s, "layer.addData", &gpkg).unwrap().reads()); // Load All's read, on a copy of the streamed layer. let path = dir.join("streamed.geojson"); std::fs::write(&path, points).unwrap(); let inline = crate::cmd::reads::read_ahead(&mut s, "layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); assert!(!inline.reads(), "an in-place opening still owns its original inline command"); let id = crate::cmd::reads::read_land(&mut s, inline.run(&Control::new()).unwrap()).unwrap()["layer"].as_u64().unwrap(); assert!(s.map().layer(id as u32).unwrap().is_streamed()); let p = json!({"layer": id}); let read = crate::cmd::reads::read_ahead(&mut s, "layer.loadAll", &p).unwrap().run(&Control::new()).unwrap(); assert_eq!(crate::cmd::reads::read_land(&mut s, read).unwrap()["features"], 2); assert!(s.read_ahead.is_none()); assert!(!s.map().layer(id as u32).unwrap().is_streamed()); let unread = |s: &Session| s.notices.iter().filter(|n| n.text.contains(" couldn't be read and w")).count(); assert_eq!(unread(&s), 0, "every row read: nothing said"); // A row that can't be read is left out and said, once, read ahead or here. let bad = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[8,47]}},{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":"x"}}]}"#; let path = dir.join("bad.geojson"); std::fs::write(&path, bad).unwrap(); for ahead in [true, false] { let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap()["layer"].as_u64().unwrap(); let p = json!({"layer": id}); let before = unread(&s); let result = if ahead { let read = crate::cmd::reads::read_ahead(&mut s, "layer.loadAll", &p).unwrap().run(&Control::new()).unwrap(); crate::cmd::reads::read_land(&mut s, read) } else { s.run("layer.loadAll", &p) }; assert_eq!(result.unwrap()["features"], 1); assert_eq!(unread(&s), before + 1, "{:?}", s.notices.iter().map(|n| &n.text).collect::>()); } // A field added while the read ran: it is read again, with that field kept. let path = dir.join("fields.geojson"); std::fs::write(&path, points).unwrap(); let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap()["layer"].as_u64().unwrap(); let p = json!({"layer": id}); let read = crate::cmd::reads::read_ahead(&mut s, "layer.loadAll", &p).unwrap().run(&Control::new()).unwrap(); let mut extra = s.map().layer(id as u32).unwrap().dataset.fields[0].clone(); extra.name = "added".into(); s.map_mut().layer_mut(id as u32).unwrap().dataset.fields.push(extra); s.run("layer.rename", &json!({"layer": id, "name": "Renamed while reading"})).unwrap(); crate::cmd::reads::read_land(&mut s, read).unwrap(); assert_eq!(s.map().layer(id as u32).unwrap().name, "Renamed while reading"); assert!(s.map().layer(id as u32).unwrap().dataset.fields.iter().any(|f| f.name == "added")); // An opening replaced at the same path cannot trigger a new read under old authority. let id = s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap()["layer"].as_u64().unwrap(); let read = crate::cmd::reads::read_ahead(&mut s, "layer.loadAll", &json!({"layer": id})).unwrap().run(&Control::new()).unwrap(); s.map_mut().layer_mut(id as u32).unwrap().source.as_mut().unwrap().reopen().unwrap(); assert!(crate::cmd::reads::read_land(&mut s, read).unwrap_err().contains("source changed")); assert!(s.map().layer(id as u32).unwrap().is_streamed() && s.read_ahead.is_none()); // A cancelled read gives nothing to add, and says so. let path = dir.join("again.csv"); std::fs::write(&path, "name,x,y\nA,8,47\n").unwrap(); let ahead = crate::cmd::reads::read_ahead(&mut s, "layer.addData", &json!({"path": path.display().to_string()})).unwrap(); let control = Control::new(); control.cancel(); assert!(ahead.run(&control).err().unwrap().contains("cancelled")); let _ = std::fs::remove_dir_all(&dir); } /// Read admission, the first real source read and delivery retain one revocable owner. #[cfg(not(target_arch = "wasm32"))] #[test] fn background_reads_keep_their_owner_before_read_and_landing() { if isolate_process_test("tests::background_reads_keep_their_owner_before_read_and_landing") { return; } use crate::cmd::reads::{read_ahead, read_land}; use crate::policy::{AgentAccess, Effects, Revocation, Scope}; use rustgis_analysis::control::Control; use rustgis_data::source::{Batch, FeatureSource, Request, SourceInfo}; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, }; struct Live(AtomicBool); impl Revocation for Live { /// This fixture revokes only its own admitted caller. fn allows(&self, _: Effects) -> bool { self.0.load(Ordering::SeqCst) } } struct OwnedSource { inner: Box, reads: Arc, } impl FeatureSource for OwnedSource { /// The real FlatGeobuf source supplies the schema. fn info(&self) -> &SourceInfo { self.inner.info() } /// The actual worker's first read must carry its admitted live predicate. fn read(&self, request: &Request) -> rustgis_data::Result { assert!(rustgis_data::source::remote::current_still().is_some_and(|still| still())); self.reads.fetch_add(1, Ordering::SeqCst); self.inner.read(request) } } let mut s = world(); let id = s.map().layer_by_name("Cities").unwrap().id; let bytes = Arc::new(rustgis_data::source::fgb::write(&s.map().layer(id).unwrap().dataset).unwrap()); let inner = rustgis_data::source::open_shared("owned.fgb", bytes.clone(), None).unwrap(); let reads = Arc::new(AtomicUsize::new(0)); s.map_mut().layer_mut(id).unwrap().source = Some(rustgis_map::source::SourceLayer::from_bytes("owned.fgb", bytes, None, Box::new(OwnedSource { inner, reads: reads.clone() }))); let live = Arc::new(Live(AtomicBool::new(true))); let owner = Scope::agent(AgentAccess::Allow, false, "owned-read", None, live.clone()); let denied = Scope::agent(AgentAccess::Refuse, false, "denied-read", None, live.clone()); // Refuse denies external reads; the owned FlatGeobuf source below is already in memory. assert!(s.with_scope(denied, |s| read_ahead(s, "layer.addData", &json!({"path":"refused-read.geojson"}))).unwrap().is_err()); s.guarded("Read fixture", false, |s| { assert!(read_ahead(s, "layer.loadAll", &json!({"layer":id})).is_err(), "a handler cannot queue work beyond its transaction"); Ok(Value::Null) }) .unwrap(); let ahead = s.with_scope(owner.clone(), |s| read_ahead(s, "layer.loadAll", &json!({"layer":id}))).unwrap().unwrap(); live.0.store(false, Ordering::SeqCst); assert!(std::thread::spawn(move || ahead.run(&Control::new())).join().unwrap().is_err()); assert_eq!(reads.load(Ordering::SeqCst), 0, "denied or revoked reads never enter their source"); live.0.store(true, Ordering::SeqCst); let ahead = s.with_scope(owner.clone(), |s| read_ahead(s, "layer.loadAll", &json!({"layer":id}))).unwrap().unwrap(); let read = std::thread::spawn(move || ahead.run(&Control::new())).join().unwrap().unwrap(); assert!(reads.load(Ordering::SeqCst) > 0); let foreign = Scope::agent(AgentAccess::Allow, false, "foreign-read", None, live.clone()); assert!(s.with_scope(foreign, |s| read_land(s, read)).unwrap().unwrap_err().contains("another caller")); assert!(s.read_ahead.is_none() && s.map().layer(id).unwrap().is_streamed()); let ahead = s.with_scope(owner.clone(), |s| read_ahead(s, "layer.loadAll", &json!({"layer":id}))).unwrap().unwrap(); let read = ahead.run(&Control::new()).unwrap(); live.0.store(false, Ordering::SeqCst); assert!(read_land(&mut s, read).unwrap_err().contains("revoked"), "revocation also refuses delivery at native root"); assert!(s.read_ahead.is_none() && s.map().layer(id).unwrap().is_streamed()); live.0.store(true, Ordering::SeqCst); s.active_layer = Some(id); let ahead = s.with_scope(owner.clone(), |s| read_ahead(s, "layer.loadAll", &json!({}))).unwrap().unwrap(); s.active_layer = s.map().layer_by_name("Countries").map(|layer| layer.id); let read = ahead.run(&Control::new()).unwrap(); assert_eq!(s.with_scope(owner, |s| read_land(s, read)).unwrap().unwrap()["layer"], id); assert!(!s.map().layer(id).unwrap().is_streamed()); assert!(s.read_ahead.is_none() && rustgis_data::source::remote::current_still().is_none()); } #[cfg(not(target_arch = "wasm32"))] #[test] fn a_models_tools_run_ahead_are_taken_by_its_run() { if isolate_process_test("tests::a_models_tools_run_ahead_are_taken_by_its_run") { return; } use crate::cmd::reads::{read_ahead, read_land}; use rustgis_analysis::control::Control; let session = || { let mut s = Session::new(Project::default()); let mut points = rustgis_data::Dataset::new("Points", rustgis_data::GeometryType::Point, Vec::new()); for (x, y) in [(8.0, 47.0), (9.0, 46.0), (10.0, 45.0)] { points.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(x, y))), Vec::new()); } s.map_mut().add_layer(rustgis_map::Layer::new(0, points)); s }; let graph = json!({"nodes": [ {"id": "points", "kind": "input", "layer": "Points"}, {"id": "near", "kind": "tool", "tool": "buffer", "params": {"distance": 1000}}, {"id": "far", "kind": "tool", "tool": "buffer", "params": {"distance": 2000}}, {"id": "output", "kind": "output", "name": "Rings"} ], "edges": [ {"from": "points", "fromPort": "out", "to": "near", "toPort": "input"}, {"from": "near", "fromPort": "out", "to": "far", "toPort": "input"}, {"from": "far", "fromPort": "out", "to": "output", "toPort": "in"} ]}); let p = json!({"graph": graph.clone()}); let rings = |s: &Session| { s.map().layer_by_name("Rings").map(|l| format!("{:?}", l.dataset.features.iter().map(|f| &f.geometry).collect::>())) }; let mut here = session(); here.run("model.run", &p).unwrap(); // Both tools are taken: what the run adds, keeps in history and puts in Undo is the same. let mut s = session(); let depth = s.undo_depth(); let read = read_ahead(&mut s, "model.run", &p).unwrap().run(&Control::stoppable()).unwrap(); let r = read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 2, "{r}"); assert!(s.read_ahead.is_none() && s.tools_ahead.is_none()); assert_eq!(rings(&s), rings(&here)); assert_eq!(s.map().layers.len(), here.map().layers.len()); assert_eq!(s.project.gp_history.len(), 2); assert!(s.model_run.as_ref().unwrap().ok); assert_eq!(s.undo_depth(), depth + 1); assert!(s.undo()); assert_eq!(s.map().layers.len(), 1); // A point added after the run ahead: the tools run here, on it too. let add = |s: &mut Session| { let p = Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(11.0, 44.0))); s.map_mut().layers[0].dataset.push_next(p, Vec::new()); }; let mut s = session(); let read = read_ahead(&mut s, "model.run", &json!({"graph": graph})).unwrap().run(&Control::stoppable()).unwrap(); add(&mut s); let mut here = session(); add(&mut here); here.run("model.run", &p).unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 0); assert!(s.tools_ahead.is_none()); assert_eq!(rings(&s), rings(&here)); // A run ahead that was cancelled is an error, and runs nothing. let control = Control::stoppable(); control.cancel(); assert!(read_ahead(&mut session(), "model.run", &p).unwrap().run(&control).is_err()); // A model saved as steps takes them too, and so does one with a tool that fails. let mut s = session(); s.run( "model.new", &json!({"name": "Steps", "steps": [ {"tool": "buffer", "params": {"input": "Points", "distance": 1000}}, {"tool": "buffer", "params": {"input": "%1", "distance": 2000}} ]}), ) .unwrap(); let p = json!({"name": "Steps"}); let read = read_ahead(&mut s, "model.run", &p).unwrap().run(&Control::stoppable()).unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 2); s.run("model.new", &json!({"name": "Fails", "steps": [{"tool": "buffer", "params": {"input": "Points", "distance": "x"}}]})).unwrap(); let p = json!({"name": "Fails"}); let read = read_ahead(&mut s, "model.run", &p).unwrap().run(&Control::stoppable()).unwrap(); let e = read_land(&mut s, read).unwrap_err(); assert!(e.contains("step 1"), "{e}"); assert_eq!(s.tools_taken, 3, "the failure is taken too"); // A tool that writes a file stops the copy: it writes once, as the run here runs it and // those after it. let dir = std::env::temp_dir().join(format!("rustgis-model-ahead-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("points.pmtiles"); let mut s = session(); s.run( "model.new", &json!({"name": "Writes", "steps": [ {"tool": "buffer", "params": {"input": "Points", "distance": 1000}}, {"tool": "vectorToPmtiles", "params": {"input": "Points", "minZoom": 0, "maxZoom": 0, "output": file.display().to_string()}}, {"tool": "buffer", "params": {"input": "Points", "distance": 2000}} ]}), ) .unwrap(); let p = json!({"name": "Writes"}); let read = read_ahead(&mut s, "model.run", &p).unwrap().run(&Control::stoppable()).unwrap(); assert!(!file.exists(), "the copy wrote nothing"); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 1); assert!(file.exists()); assert_eq!(s.project.gp_history.len(), 3); // A selection made meanwhile is an input changed: the tools run here, on it. let mut s = session(); let read = read_ahead(&mut s, "model.run", &json!({"graph": graph})).unwrap().run(&Control::stoppable()).unwrap(); s.map_mut().layers[0].selection = vec![0]; read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 0); assert_eq!(s.map().layer_by_name("Rings").unwrap().dataset.features.len(), 1); // The copied session must not hide a change to the initiating session's login. let p = json!({"graph":graph.clone()}); let mut s = session(); let connection = s .run( "connection.new", &json!({ "kind":"wms", "address":"https://model-owner.example.invalid", "auth":"header", "keyName":"X-Key" }), ) .unwrap()["id"] .as_str() .unwrap() .to_string(); s.run("connection.login", &json!({"id":connection,"apiKey":"RGSYNTH_model_owner_before"})).unwrap(); let ahead = read_ahead(&mut s, "model.run", &p).unwrap(); assert!(ahead.reads(), "the native model keeps upstream off-frame execution"); let read = ahead.run(&Control::stoppable()).unwrap(); s.run("connection.login", &json!({"id":connection,"apiKey":"RGSYNTH_model_owner_after"})).unwrap(); let before = serde_json::to_value(&s.project).unwrap(); let error = read_land(&mut s, read).unwrap_err(); assert!(error.contains("account"), "{error}"); assert_eq!(s.tools_taken, 0); assert_eq!(serde_json::to_value(&s.project).unwrap(), before); // A scoped caller stays on its original session instead of becoming User in a copy. use crate::policy::{AgentAccess, Effects, Revocation, Scope}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; struct Live(AtomicBool); impl Revocation for Live { /// Revokes only this fixture's initiating caller. fn allows(&self, _: Effects) -> bool { self.0.load(Ordering::SeqCst) } } for command in ["model.run", "script.run"] { let params = if command == "model.run" { p.clone() } else { json!({"script":format!("model.run {p}")}) }; let mut s = session(); let live = Arc::new(Live(AtomicBool::new(true))); let denied = Scope::agent(AgentAccess::Allow, false, "model-denied", None, live.clone()); let error = s.with_scope(denied, |s| read_ahead(s, command, ¶ms)).unwrap().err().unwrap(); assert!(error.contains("reserved"), "{error}"); let scope = Scope::agent(AgentAccess::Full, false, "model-owner", None, live.clone()); let ahead = s.with_scope(scope.clone(), |s| read_ahead(s, command, ¶ms)).unwrap().unwrap(); assert!(!ahead.reads(), "scoped work is not run in a native User copy"); live.0.store(false, Ordering::SeqCst); assert!(ahead.run(&Control::stoppable()).err().unwrap().contains("revoked")); live.0.store(true, Ordering::SeqCst); let read = s.with_scope(scope.clone(), |s| read_ahead(s, command, ¶ms)).unwrap().unwrap().run(&Control::stoppable()).unwrap(); live.0.store(false, Ordering::SeqCst); assert!(read_land(&mut s, read).unwrap_err().contains("revoked")); assert!(rings(&s).is_none()); live.0.store(true, Ordering::SeqCst); let read = s.with_scope(scope.clone(), |s| read_ahead(s, command, ¶ms)).unwrap().unwrap().run(&Control::stoppable()).unwrap(); let foreign = Scope::agent(AgentAccess::Full, false, "another-model-owner", None, live.clone()); assert!(s.with_scope(foreign, |s| read_land(s, read)).unwrap().unwrap_err().contains("another caller")); let read = s.with_scope(scope.clone(), |s| read_ahead(s, command, ¶ms)).unwrap().unwrap().run(&Control::stoppable()).unwrap(); s.with_scope(scope, |s| read_land(s, read)).unwrap().unwrap(); assert!(rings(&s).is_some()); assert_eq!(s.tools_taken, 0, "scoped execution ran its own admitted tools"); } std::fs::remove_dir_all(&dir).ok(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn a_scripts_tools_run_ahead_are_taken_by_its_run() { if isolate_process_test("tests::a_scripts_tools_run_ahead_are_taken_by_its_run") { return; } use crate::cmd::reads::{read_ahead, read_land}; use rustgis_analysis::control::Control; let session = || { let mut s = Session::new(Project::default()); let mut points = rustgis_data::Dataset::new("Points", rustgis_data::GeometryType::Point, Vec::new()); for (x, y) in [(8.0, 47.0), (9.0, 46.0), (10.0, 45.0)] { points.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(x, y))), Vec::new()); } s.map_mut().add_layer(rustgis_map::Layer::new(0, points)); s.run("model.new", &json!({"name": "Steps", "steps": [{"tool": "buffer", "params": {"input": "Points", "distance": 1000}}]})) .unwrap(); s }; let tool = |d: u32| format!(r#"gp.run {{"tool": "buffer", "params": {{"input": "Points", "distance": {d}}}}}"#); let layers = |s: &Session| { s.map() .layers .iter() .map(|l| (l.name.clone(), format!("{:?}", l.dataset.features.iter().map(|f| &f.geometry).collect::>()))) .collect::>() }; // Its tools, a selection between them and a model it runs are taken: what the run adds and // keeps in history is the same, as one step to undo. let p = json!({"script": format!("{}\nselect.all {{\"layer\": \"Points\"}}\n{}\nmodel.run {{\"name\": \"Steps\"}}", tool(1000), tool(2000))}); let mut here = session(); here.run("script.run", &p).unwrap(); let mut s = session(); let depth = s.undo_depth(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); let r = read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 3, "{r}"); assert!(s.read_ahead.is_none() && s.tools_ahead.is_none()); assert_eq!(layers(&s), layers(&here)); assert_eq!(s.project.gp_history.len(), here.project.gp_history.len()); assert_eq!(s.undo_depth(), depth + 1); // A line that may reach outside the session stops the copy: the run here runs it and what // follows, so the project is saved once. let dir = std::env::temp_dir().join(format!("rustgis-script-ahead-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let saved = dir.join("saved.rgis"); let p = json!({"script": format!("{}\nproject.save {}\n{}", tool(1000), json!({"path": saved.display().to_string()}), tool(2000))}); let mut s = session(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); assert!(!saved.exists(), "the copy saved nothing"); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 1); assert!(saved.exists()); assert_eq!(s.project.gp_history.len(), 2); std::fs::remove_dir_all(&dir).ok(); // Spatial Join with its output named stays on the copy: its name is a layer's, not a file's, // so it and the tool after it are taken, and the layer is made under that name. let join = r#"gp.spatialJoin {"target": "Points", "join": "Points", "output": "Joined"}"#; let p = json!({"script": format!("{join}\n{}", tool(1000))}); let mut s = session(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 2); assert!(s.map().layer_by_name("Joined").is_some()); // An edit stops the copy (the rows it makes there are never the run here's): the tool // before it is taken, the one after it runs here. let p = json!({"script": format!("{}\nedit.addField {{\"layer\": \"Points\", \"name\": \"n\", \"type\": \"long\"}}\n{}", tool(1000), tool(2000))}); let mut s = session(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 1); assert!(s.map().layer_by_name("Points").unwrap().dataset.fields.iter().any(|f| f.name == "n")); // A selection changed meanwhile: the tools after it run here, on it. let p = json!({"script": tool(1000)}); let mut s = session(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); s.map_mut().layers[0].selection = vec![0]; read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 0); // A script whose first line would stop the copy runs here, with no copy made. let p = json!({"script": format!("edit.addField {{\"layer\": \"Points\", \"name\": \"m\", \"type\": \"long\"}}\n{}", tool(1000))}); assert!(!read_ahead(&mut s, "script.run", &p).unwrap().reads()); assert!(!read_ahead(&mut s, "script.run", &json!({"script": "# nothing"})).unwrap().reads()); // Each command a line may run on the copy is one the session has. for id in crate::cmd::workflows::ON_A_COPY { assert!(s.registry.get(id).is_some(), "{id}"); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn reload_is_refused_by_imported_workflows_but_runs_in_a_trusted_task() { let path = grid_fgb("workflow-reload", 2); let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap()["layer"].clone(); let task = json!({"name": "Reload", "steps": [{"title": "Reload", "command": "layer.reloadData", "params": {"layer": id}}]}); assert!(s.run("task.import", &json!({"text": task.to_string()})).unwrap_err().contains("reads or writes files")); s.run("task.new", &task).unwrap(); let mut untrusted = Session::new(s.project.clone()); assert!(untrusted.run("task.run", &json!({"task": "Reload"})).unwrap_err().contains("reads or writes files")); let notebook = json!({ "nbformat": 4, "metadata": {"language_info": {"name": "rustgis"}}, "cells": [{"cell_type": "code", "source": format!("layer.reloadData {{\"layer\": {id}}}")}] }); s.run("notebook.open", &json!({"text": notebook.to_string()})).unwrap(); let result = s.run("notebook.run", &json!({"cell": 0})).unwrap(); assert_eq!(result["error"], 0); let cell = &s.project.notebooks.last().unwrap().cells[0]; assert!(cell.error && cell.output.as_deref().is_some_and(|text| text.contains("reads or writes files"))); let source = s.map().layer(id.as_u64().unwrap() as u32).unwrap().source.clone().unwrap(); s.run("task.run", &json!({"task": "Reload"})).unwrap(); assert!(!s.map().layer(id.as_u64().unwrap() as u32).unwrap().source.as_ref().unwrap().same_opening(&source)); std::fs::remove_file(path).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn zarr_reload_rejects_wholly_unreachable_dates_but_allows_a_reachable_current_gap() { let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("reload-dates"); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; s.run("time.set", &json!({"dates": ["2002-01-01", "2002-02-01", "2002-03-01"], "date": "2002-01-01"})).unwrap(); let before = serde_json::to_value(s.map().layer(id).unwrap()).unwrap(); let slider = s.map().time.clone(); let depth = s.undo_depth(); let error = s.run("layer.reloadData", &json!({"layer": id})).unwrap_err(); assert!(error.contains("adjust the time slider") && error.contains("retry"), "{error}"); assert_eq!(s.map().time, slider); assert_eq!(s.undo_depth(), depth); assert_eq!(serde_json::to_value(s.map().layer(id).unwrap()).unwrap(), before); s.run("time.set", &json!({"dates": ["2000-01-01", "2001-01-01", "2001-02-01", "2001-03-01"], "date": "2000-01-01"})).unwrap(); let slider = s.map().time.clone(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); assert_eq!(s.map().time, slider); assert!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.is_none()); s.run("time.goTo", &json!({"step": 1})).unwrap(); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(8.0)); // A bounding range alone is insufficient: monthly windows can leave calendar gaps. let (gap_dir, gap_path) = zarr_store_at("reload-calendar-dates", &[86, 87, 88]); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path": gap_path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; s.run("time.set", &json!({"dates": [], "start": "2001-01-31", "end": "2001-03-31", "unit": "month", "interval": 1, "before": 0, "after": 1, "cumulative": false})).unwrap(); let slider = s.map().time.clone(); let error = s.run("layer.reloadData", &json!({"layer": id})).unwrap_err(); assert!(error.contains("outside every time slider window"), "{error}"); assert_eq!(s.map().time, slider); std::fs::remove_dir_all(gap_dir).unwrap(); std::fs::remove_dir_all(dir).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn zarr_reload_preserves_selected_dates_when_earlier_steps_are_inserted_and_refuses_changed_periods() { let _cache = zarr_cache_lock(); let tag = "reload-selected-period"; let (dir, path) = zarr_store_over_time(tag); let mut s = Session::new(Project::default()); let id = s .run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-02-01", "end": "2001-03-02"})) .unwrap()["layer"] .as_u64() .unwrap() as u32; let prior = s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.clone().unwrap(); assert_eq!(prior.offset, 1); assert_eq!(prior.dates.len(), 2); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(108.0)); zarr_store_at(tag, &[-31, 0, 31, 59]); let slider = s.map().time.clone(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); let raster = s.map().layer(id).unwrap().raster.as_ref().unwrap(); assert_eq!(raster.time_zarr.as_ref().unwrap().offset, 2); assert_eq!(raster.time_zarr.as_ref().unwrap().dates, prior.dates); assert_eq!(raster.data.as_ref().unwrap().value(0, 0, 0), Some(208.0), "February moved to the third chunk"); assert_eq!(s.map().time, slider); s.run("time.goTo", &json!({"step": 1})).unwrap(); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(308.0)); for days in [&[-31, 0, 32, 59][..], &[-31, 0, 31, 60][..], &[-31, 0, 31, 45, 59][..], &[-31, 0, 31][..]] { zarr_store_at(tag, days); let before = serde_json::to_value(s.map().layer(id).unwrap()).unwrap(); let raster = s.map().layer(id).unwrap().raster.as_ref().unwrap(); let pixels = raster.data.clone().unwrap(); let selected = raster.time_zarr.clone(); let slider = s.map().time.clone(); let depth = s.undo_depth(); let error = s.run("layer.reloadData", &json!({"layer": id})).unwrap_err(); assert!(error.contains("no longer contains the selected time period unchanged"), "{days:?}: {error}"); assert_eq!(serde_json::to_value(s.map().layer(id).unwrap()).unwrap(), before); let raster = s.map().layer(id).unwrap().raster.as_ref().unwrap(); assert_eq!(raster.time_zarr, selected); assert!(std::sync::Arc::ptr_eq(&pixels, raster.data.as_ref().unwrap())); assert_eq!(s.map().time, slider); assert_eq!(s.undo_depth(), depth); } std::fs::remove_dir_all(dir).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn prepared_reload_validates_live_presentation_and_refuses_stale_source_without_fallback() { use rustgis_analysis::control::Control; for change in ["camera", "presentation", "missing binding", "reopen", "path", "second reload"] { let path = grid_fgb("prepared-reload", 2); let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap()["layer"].as_u64().unwrap() as u32; let p = json!({"layer": id}); let read = crate::cmd::reads::read_ahead(&mut s, "layer.reloadData", &p).unwrap().run(&Control::new()).unwrap(); match change { "camera" => s.map_mut().camera.res *= 0.5, "presentation" => s.map_mut().layer_mut(id).unwrap().transparency = 42.0, "missing binding" => { let l = s.map_mut().layer_mut(id).unwrap(); l.dataset.fields.push(rustgis_data::Field::new("new_field", rustgis_data::FieldType::Integer)); l.labels.default.field = "new_field".into(); } "reopen" => s.map_mut().layer_mut(id).unwrap().source.as_mut().unwrap().reopen().unwrap(), "path" => s.map_mut().layer_mut(id).unwrap().source.as_mut().unwrap().path = Some("missing.fgb".into()), "second reload" => { s.run("layer.reloadData", &p).unwrap(); } _ => panic!("unexpected reload scenario"), } let before = serde_json::to_value(s.map().layer(id).unwrap()).unwrap(); let depth = s.undo_depth(); std::fs::remove_file(&path).unwrap(); let result = crate::cmd::reads::read_land(&mut s, read); if matches!(change, "camera" | "presentation") { result.unwrap(); if change == "presentation" { assert_eq!(s.map().layer(id).unwrap().transparency, 42.0); } } else { let error = result.unwrap_err(); assert!( error.contains("changed while reading") || error.contains("source or account changed") || error.contains("new_field"), "{change}: {error}" ); assert_eq!(serde_json::to_value(s.map().layer(id).unwrap()).unwrap(), before); assert_eq!(s.undo_depth(), depth); } } } #[cfg(not(target_arch = "wasm32"))] #[test] fn prepared_reload_never_discards_geopackage_edits_or_baselines_changed_after_confirmation() { use rustgis_analysis::control::Control; use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value as Cell, raster::Crs, source::gpkg}; for change in ["new edit", "schema", "baseline", "source"] { let path = std::env::temp_dir().join(format!("rustgis-prepared-gpkg-{}-{change}.gpkg", std::process::id())); let mut ds = Dataset::new("Rows", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); ds.push(Some(Geometry::Point(Coord::new(2.0, 48.0))), vec![Cell::Text("original".into())]); gpkg::write_in(&ds, &path, Crs::LonLat).unwrap(); let mut s = Session::new(Project::default()); let id = s.run("gpkg.addLayer", &json!({"path": path, "table": "Rows"})).unwrap()["layer"].as_u64().unwrap() as u32; s.run("edit.setValue", &json!({"layer": id, "row": 0, "field": "name", "value": "confirmed edit"})).unwrap(); let p = json!({"layer": id, "discardEdits": true}); let read = crate::cmd::reads::read_ahead(&mut s, "layer.reloadData", &p).unwrap().run(&Control::new()).unwrap(); match change { "new edit" => { s.run("edit.setValue", &json!({"layer": id, "row": 0, "field": "name", "value": "unseen edit"})).unwrap(); } "schema" => s.map_mut().layer_mut(id).unwrap().dataset.fields.push(Field::new("new_column", FieldType::Text)), "baseline" => { s.run("gpkg.discardEdits", &json!({"layer": id})).unwrap(); } "source" => s.map_mut().layer_mut(id).unwrap().gpkg.as_mut().unwrap().table = "different".into(), _ => panic!("unexpected reload scenario"), } let before = s.map().layer(id).unwrap().dataset.clone(); let edit = s.map().layer(id).unwrap().gpkg.clone(); let bytes = std::fs::read(&path).unwrap(); let depth = s.undo_depth(); let error = crate::cmd::reads::read_land(&mut s, read).unwrap_err(); assert!(error.contains("changed while reading") || error.contains("source or account changed"), "{change}: {error}"); assert_eq!(s.map().layer(id).unwrap().dataset, before); assert_eq!(s.map().layer(id).unwrap().gpkg, edit); assert_eq!(s.undo_depth(), depth); assert_eq!(std::fs::read(&path).unwrap(), bytes, "a stale confirmation never writes"); std::fs::remove_file(path).unwrap(); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn prepared_editable_reload_preserves_a_name_changed_while_reading() { use rustgis_analysis::control::Control; use rustgis_data::{Coord, Dataset, Geometry, GeometryType, raster::Crs, source::gpkg}; let path = std::env::temp_dir().join(format!("rustgis-prepared-reload-renamed-{}.gpkg", std::process::id())); let mut ds = Dataset::new("Rows", GeometryType::Point, vec![]); ds.push(Some(Geometry::Point(Coord::new(2.0, 48.0))), vec![]); gpkg::write_in(&ds, &path, Crs::LonLat).unwrap(); let mut s = Session::new(Project::default()); let id = s.run("gpkg.addLayer", &json!({"path": path, "table": "Rows"})).unwrap()["layer"].as_u64().unwrap() as u32; let read = crate::cmd::reads::read_ahead(&mut s, "layer.reloadData", &json!({"layer": id})).unwrap().run(&Control::new()).unwrap(); s.run("layer.rename", &json!({"layer": id, "name": "Renamed while reading"})).unwrap(); crate::cmd::reads::read_land(&mut s, read).unwrap(); let layer = s.map().layer(id).unwrap(); assert_eq!(layer.name, "Renamed while reading"); assert_eq!(layer.dataset.name, "Renamed while reading"); assert_eq!(layer.dataset.features.len(), 1); drop(s); std::fs::remove_file(path).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn prepared_zarr_reload_uses_worker_pixels_and_refuses_configuration_time_or_opening_drift() { use rustgis_analysis::control::Control; let _cache = zarr_cache_lock(); for change in ["camera", "style", "in place", "time", "second reload"] { let (dir, path) = zarr_store_over_time(&format!("prepared-{change}")); let mut s = Session::new(Project::default()); let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; let p = json!({"layer": id}); let read = crate::cmd::reads::read_ahead(&mut s, "layer.reloadData", &p).unwrap().run(&Control::new()).unwrap(); match change { "camera" => s.map_mut().camera.res *= 0.5, "style" => s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().style.gamma = 1.7, "in place" => s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().in_place = true, "time" => { s.run("time.goTo", &json!({"step": 1})).unwrap(); } "second reload" => { s.run("layer.reloadData", &p).unwrap(); } _ => panic!("unexpected reload scenario"), } let before = s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone(); let depth = s.undo_depth(); std::fs::write( std::path::Path::new(&path).join("t/0.0.0"), std::iter::repeat_n(700.0f32, 12).flat_map(|v| v.to_le_bytes()).collect::>(), ) .unwrap(); let result = crate::cmd::reads::read_land(&mut s, read); if change == "camera" { result.unwrap(); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(8.0)); } else { assert!(result.unwrap_err().contains("changed while reading")); assert_eq!(s.undo_depth(), depth); assert!(std::sync::Arc::ptr_eq( before.as_ref().unwrap(), s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap() )); } std::fs::remove_dir_all(dir).unwrap(); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn reopening_zarr_twice_does_not_reuse_cached_or_late_reads_from_the_previous_opening() { use crate::render::rustgis_tiles::cache; use std::sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, mpsc, }; let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("reopen-generation"); let mut original = Session::new(Project::default()); let id = original.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; let saved = serde_json::to_string(&original.project).unwrap(); let mut s = Session::new(Project::default()); s.run("project.openText", &json!({"text": saved})).unwrap(); s.zarr_off_frame = true; let prior = s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.clone().unwrap(); let cached_key = crate::cmd::zarr::slice_key(&prior, 1); assert_eq!(crate::cmd::zarr::read_step(&prior, 1, cached_key.clone()).unwrap().value(0, 0, 0), Some(108.0)); let old_key = crate::cmd::zarr::slice_key(&prior, 2); let caller = std::thread::current().id(); let checks = AtomicUsize::new(0); let (started, entered) = mpsc::sync_channel(1); let (release, released) = mpsc::sync_channel(1); let released = Mutex::new(released); let still: cache::Still = Arc::new(move || { // The second check is after the old pixels were read, before they are published. if std::thread::current().id() != caller && checks.fetch_add(1, Ordering::SeqCst) == 1 { let _ = started.send(()); return released.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).is_ok(); } true }); let mark = s.notice_mark(); cache::with_still(still.clone(), || s.run("time.goTo", &json!({"step": 2}))).unwrap(); entered.recv_timeout(std::time::Duration::from_secs(10)).expect("the old read holds its pixels before completion"); assert!(s.zarr_waiting()); assert!(crate::cmd::zarr::kept_slice(&old_key).is_none(), "the old step is genuinely pending, not cached"); let saved = serde_json::to_string(&s.project).unwrap(); for step in 0..3 { std::fs::write( std::path::Path::new(&path).join(format!("t/{step}.0.0")), std::iter::repeat_n(500.0f32 + step as f32, 12).flat_map(|v| v.to_le_bytes()).collect::>(), ) .unwrap(); } // Reuse the same session and requesting lifetime: only the opening generation separates // the new layer's request from the old pending completion in ReadWaits::shared. cache::with_still(still, || s.run("project.openText", &json!({"text": saved}))).unwrap(); assert_ne!(s.map().layer(id).unwrap().raster.as_ref().unwrap().time_zarr.as_ref().unwrap().read_generation, prior.read_generation); release.send(()).unwrap(); let end = std::time::Instant::now() + std::time::Duration::from_secs(10); while s.zarr_waiting() || crate::cmd::zarr::kept_slice(&old_key).is_none() { assert!(std::time::Instant::now() < end, "the pending reads never completed"); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(2)); } assert_eq!(crate::cmd::zarr::kept_slice(&old_key).unwrap().value(0, 0, 0), Some(208.0), "the late completion contains old pixels"); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(502.0)); s.run("time.goTo", &json!({"step": 1})).unwrap(); while s.zarr_waiting() { assert!(std::time::Instant::now() < end, "the fresh cached-step replacement never landed"); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(2)); } assert_eq!(crate::cmd::zarr::kept_slice(&cached_key).unwrap().value(0, 0, 0), Some(108.0)); assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0), Some(501.0)); assert!(!s.notices_since(mark).iter().any(|notice| notice.error)); std::fs::remove_dir_all(dir).unwrap(); } /// A reload installs a new opening of the store: a layer asking for a step another read of the /// old opening still holds must read the rewritten chunks, not join that read. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zarr_step_requested_after_reload_does_not_join_a_pending_read_of_the_old_store() { use crate::render::rustgis_tiles::cache; use std::sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, mpsc, }; let _cache = zarr_cache_lock(); let (dir, path) = zarr_store_over_time("reload-shared-read"); let mut s = Session::new(Project::default()); s.zarr_off_frame = true; let id = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true})).unwrap()["layer"].as_u64().unwrap() as u32; let first = |s: &Session| s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().value(0, 0, 0); assert_eq!(first(&s), Some(8.0)); let caller = std::thread::current().id(); let checks = AtomicUsize::new(0); let (started, entered) = mpsc::sync_channel(1); let (release, released) = mpsc::sync_channel(1); let released = Mutex::new(released); let still: cache::Still = Arc::new(move || { // The second check is after the old pixels were read, before they are published. if std::thread::current().id() != caller && checks.fetch_add(1, Ordering::SeqCst) == 1 { let _ = started.send(()); return released.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).is_ok(); } true }); cache::with_still(still.clone(), || s.run("time.goTo", &json!({"step": 1}))).unwrap(); entered.recv_timeout(std::time::Duration::from_secs(10)).expect("the old read holds its pixels before completion"); assert!(s.zarr_waiting()); // The slider returns to the step shown, so only the held worker still owns the old read. s.run("time.goTo", &json!({"step": 0})).unwrap(); assert!(!s.zarr_waiting()); for step in 0..3 { std::fs::write( std::path::Path::new(&path).join(format!("t/{step}.0.0")), std::iter::repeat_n(500.0f32 + step as f32, 12).flat_map(|v| v.to_le_bytes()).collect::>(), ) .unwrap(); } s.run("layer.reloadData", &json!({"layer": id})).unwrap(); assert_eq!(first(&s), Some(500.0)); // The same step, store, selection and lifetime as the held read: only the reload separates them. cache::with_still(still, || s.run("time.goTo", &json!({"step": 1}))).unwrap(); release.send(()).unwrap(); let end = std::time::Instant::now() + std::time::Duration::from_secs(10); while s.zarr_waiting() { assert!(std::time::Instant::now() < end, "the step never landed"); s.follow_time_landed(); std::thread::sleep(std::time::Duration::from_millis(2)); } assert_eq!(first(&s), Some(501.0), "the layer shows the rewritten chunks, not the pre-reload step"); std::fs::remove_dir_all(dir).unwrap(); } #[cfg(not(target_arch = "wasm32"))] #[test] fn a_scripts_batch_runs_ahead_and_stops_with_its_cancel() { if isolate_process_test("tests::a_scripts_batch_runs_ahead_and_stops_with_its_cancel") { return; } use crate::cmd::reads::{read_ahead, read_land}; use rustgis_analysis::control::Control; let dir = std::env::temp_dir().join(format!("rustgis-batch-ahead-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("towns.csv"); std::fs::write(&file, "name,x,y\nA,8,47\nB,9,46\n").unwrap(); let session = || { let mut s = Session::new(Project::default()); for name in ["North", "South"] { let mut points = rustgis_data::Dataset::new(name, rustgis_data::GeometryType::Point, Vec::new()); points.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(8.0, 47.0))), Vec::new()); s.map_mut().add_layer(rustgis_map::Layer::new(0, points)); } s }; let batch = json!({"tool": "buffer", "inputs": ["North", "South", file.display().to_string()], "params": {"distance": 1000}}); let p = json!({"script": format!("gp.batch {batch}")}); let layers = |s: &Session| s.map().layers.iter().map(|l| (l.name.clone(), l.dataset.features.len())).collect::>(); let mut here = session(); here.run("script.run", &p).unwrap(); // A batch runs on a copy of the session, with Cancel in the status bar: each item's tool and // the file it read are taken by the run here, which reads the file no more. let mut s = session(); let ahead = read_ahead(&mut s, "script.run", &p).unwrap(); assert!(ahead.reads(), "a batch runs on a thread of its own"); let read = ahead.run(&Control::stoppable()).unwrap(); let r = read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 3, "{r}"); assert!(s.read_ahead.is_none() && s.tools_ahead.is_none() && s.files_ahead.is_none()); assert_eq!(layers(&s), layers(&here)); assert_eq!(s.project.gp_history.len(), here.project.gp_history.len()); assert!(s.notices.iter().any(|n| n.text == "Batch 'buffer' complete: all 3 succeeded"), "{:?}", s.notices); // The file changed after the copy read it: the run here reads it again, and runs its tool. let mut s = session(); let read = read_ahead(&mut s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap(); std::fs::write(&file, "name,x,y\nA,8,47\nB,9,46\nC,10,45\n").unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 2); assert!(s.files_ahead.is_none()); assert_eq!(s.map().layer_by_name("towns_buffer").map(|l| l.dataset.features.len()), Some(3)); // Cancelled while it runs: the batch stops between items, and the run adds nothing. let mut s = session(); let control = Control::stoppable(); control.cancel(); assert!(read_ahead(&mut s, "script.run", &p).unwrap().run(&control).err().is_some_and(|e| e.contains("cancelled"))); assert_eq!(s.map().layers.len(), 2); std::fs::remove_dir_all(&dir).ok(); } #[test] fn topology_validate_says_when_a_polar_check_is_incomplete() { // A line winding round the North Pole about 19,000 times: its dangle check measures it coarsely, // so the rule is reported as incomplete, once, in a notice. let c = rustgis_geom::Coord::new; let mut s = Session::new(Project::default()); let mut winding = rustgis_data::Dataset::new("Winding", rustgis_data::GeometryType::Polyline, vec![]); winding.push(Some(rustgis_data::Geometry::Line(vec![vec![c(0.0, 89.9999), c(7_000_000.0, 89.9999)]])), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, winding)); s.run("topology.rules", &json!({"add": {"rule": "mustNotHaveDangles", "layer": "Winding"}})).unwrap(); let mark = s.notice_mark(); s.run("topology.validate", &json!({"toleranceM": 20})).unwrap(); let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); assert_eq!(said.iter().filter(|t| t.starts_with("1 topology rule(s) weren't checked completely")).count(), 1, "{said:?}"); // A line measured as closely as it needs to be says nothing of the kind. s.run("topology.rules", &json!({"clear": true})).unwrap(); let mut short = rustgis_data::Dataset::new("Short", rustgis_data::GeometryType::Polyline, vec![]); short.push(Some(rustgis_data::Geometry::Line(vec![vec![c(0.0, 89.9999), c(2_000.0, 89.9999)]])), vec![]); s.map_mut().add_layer(rustgis_map::Layer::new(0, short)); s.run("topology.rules", &json!({"add": {"rule": "mustNotHaveDangles", "layer": "Short"}})).unwrap(); let mark = s.notice_mark(); s.run("topology.validate", &json!({"toleranceM": 20})).unwrap(); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("weren't checked completely"))); } #[test] fn radius_commands_report_invalid_latitudes_and_missing_coordinates() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; for command in ["analysis.neighborhood", "select.byRadius"] { for bad in [true, false] { let mut s = Session::new(Project::default()); let mut data = Dataset::new("Input", GeometryType::Point, vec![]); data.push(Some(Geometry::Point(Coord::new(0.0, 85.0))), vec![]); if bad { data.push(Some(Geometry::Point(Coord::new(0.0, 95.0))), vec![]); data.push(Some(Geometry::MultiPoint(vec![Coord::new(f64::NAN, 86.0), Coord::new(0.0, 86.0)])), vec![]); } s.map_mut().add_dataset(data); let mark = s.notice_mark(); s.run(command, &json!({"lon":0, "lat":84, "radius":50_000})).unwrap(); let notices = s.notices_since(mark); assert_eq!( notices.iter().filter(|n| n.text.contains("latitude past ±90°")).count(), usize::from(bad), "{command}: {notices:?}" ); assert_eq!( notices.iter().filter(|n| n.text.contains("missing or infinite coordinates")).count(), usize::from(bad), "{command}: {notices:?}" ); } } } #[test] fn topology_refuses_latitudes_past_the_poles() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; for latitude in [89.0, 95.0] { let mut s = Session::new(Project::default()); let mut data = Dataset::new("Paths", GeometryType::Polyline, vec![]); data.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 88.0), Coord::new(0.0, latitude)]])), vec![]); s.map_mut().add_dataset(data); s.run("topology.rules", &json!({"add":{"rule":"mustNotHaveDangles", "layer":"Paths"}})).unwrap(); let result = s.run("topology.validate", &Value::Null); if latitude > 90.0 { assert!(result.unwrap_err().contains("latitude past ±90°")); } else { result.unwrap(); } } } #[test] fn importing_a_file_that_is_neither_map_nor_project_says_so() { // Neither a map nor a project: the refusal names what the import takes, not a failed project. let mut s = world(); let e = s.run("map.import", &json!({"text": r#"{"name": 5, "layers": 3}"#})).unwrap_err(); assert!(e.contains("not a RustGIS map file, project or package"), "{e}"); // Text that isn't JSON at all is said to be none of them the same way. let e = s.run("map.import", &json!({"text": "not json"})).unwrap_err(); assert!(e.contains("not a RustGIS map file, project or package"), "{e}"); assert!(!e.contains("not a RustGIS project"), "{e}"); let e = s.run("style.import", &json!({"text": r#"{"name": 5, "layers": 3}"#})).unwrap_err(); assert!(e.contains("not a RustGIS layer file, map file or project"), "{e}"); let e = s.run("style.import", &json!({"text": "not json"})).unwrap_err(); assert!(e.contains("not a RustGIS layer file, map file or project"), "{e}"); // A project from a newer RustGIS keeps its advice. let e = s.run("map.import", &json!({"text": r#"{"format": 99999, "maps": 3}"#})).unwrap_err(); assert!(e.contains("Update RustGIS"), "{e}"); } #[test] fn model_import_refusals_name_their_cause() { let mut s = world(); // A file that is no model or web tool is said to be one, not told to update RustGIS. for text in [r#"{"type": "FeatureCollection", "features": []}"#, r#"{"version": 99, "name": "x"}"#] { let e = s.run("model.import", &json!({"text": text})).unwrap_err(); assert!(e.contains("not a RustGIS model or web tool (no graph or steps)"), "{e}"); } // A model by its schema, or a web tool by its format, from a newer RustGIS that holds // something else is told to update. for text in [ json!({"$schema": crate::model::SCHEMA, "version": "2.0.0", "name": "New", "nodes": []}), json!({"format": "RustGIS web tool", "version": 2, "name": "New"}), ] { let e = s.run("model.import", &json!({"text": text.to_string()})).unwrap_err(); assert!(e.contains("Update RustGIS"), "{e}"); } // A model whose nodes would be dropped says the causes it may have. let graph = json!({"nodes": [{"id": "a", "kind": "input", "layer": "Cities"}, {"id": "a", "kind": "input", "layer": "Countries"}], "edges": []}); let text = json!({"$schema": crate::model::SCHEMA, "version": "1.0.0", "name": "Dup", "graph": graph}).to_string(); let e = s.run("model.import", &json!({"text": text})).unwrap_err(); assert!(e.contains("would discard 1 node(s)") && e.contains("repeated connections into a single-input port"), "{e}"); } /// `analysis.interpolate` says how many features it leaves out, and why: those with no place to /// stand for and those whose value isn't a number. A layer it reads whole says nothing. #[test] fn interpolation_says_the_features_it_leaves_out() { use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as V}; use rustgis_geom::Coord; let mut s = world(); let mut wells = Dataset::new("wells", GeometryType::Point, vec![Field::new("depth", FieldType::Double)]); for (i, depth) in [10.0, 20.0, 30.0].into_iter().enumerate() { wells.push(Some(Geometry::Point(Coord::new(i as f64, i as f64))), vec![V::Float(depth)]); } crate::cmd::layer::add_dataset(&mut s, wells.clone()); let mark = s.notice_mark(); let v = s.run("analysis.interpolate", &json!({"layer": "wells", "field": "depth", "cellSize": 0.5})).unwrap(); assert!(v.get("skipped").is_none(), "{v}"); assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("left out")), "{:?}", s.notices_since(mark)); wells.name = "lossy wells".into(); wells.push(None, vec![V::Float(40.0)]); wells.push(Some(Geometry::Point(Coord::new(f64::NAN, 1.0))), vec![V::Float(50.0)]); wells.push(Some(Geometry::Point(Coord::new(1.5, 0.5))), vec![V::Null]); crate::cmd::layer::add_dataset(&mut s, wells); let mark = s.notice_mark(); let v = s.run("analysis.interpolate", &json!({"layer": "lossy wells", "field": "depth", "cellSize": 0.5})).unwrap(); assert_eq!(v["skipped"], json!({"noPoint": 2, "noValue": 1}), "{v}"); let said: Vec<_> = s.notices_since(mark).iter().filter(|n| n.text.contains("left out")).map(|n| n.text.clone()).collect(); assert_eq!( said, ["lossy wells: features left out of the surface: 2 with no geometry or no usable point, 1 whose depth isn't a number"] ); // A surface with no usable points left fails, and the error says what was left out. let mut bad = Dataset::new("bad wells", GeometryType::Point, vec![Field::new("depth", FieldType::Double)]); bad.push(Some(Geometry::Point(Coord::new(f64::NAN, 1.0))), vec![V::Float(1.0)]); bad.push(None, vec![V::Float(2.0)]); bad.push(Some(Geometry::Point(Coord::new(1.0, 1.0))), vec![V::Null]); crate::cmd::layer::add_dataset(&mut s, bad); let e = s.run("analysis.interpolate", &json!({"layer": "bad wells", "field": "depth", "cellSize": 0.5})).unwrap_err(); assert!(e.contains("(2 features with no geometry or no usable point, 1 whose value isn't a number left out)"), "{e}"); } /// `share.exportMap` to a `.tif` writes the view placed on the ground (#1561): in the map's /// projection, each pixel where the view drew it, RGB; a turned view north up over its box, and /// says so; a tilted view, the globe and junk refused. #[test] fn export_map_to_geotiff_places_the_view_on_the_ground() { use rustgis_data::raster::{Crs, geotiff}; use rustgis_geom::{Coord, lonlat_to_merc}; let mut s = world(); s.map_mut().basemap = rustgis_map::Basemap::Blank; // The export's size is the view's, so its pixel is the camera's resolution. s.view_size = (400.0, 300.0); s.run("map.goToXY", &json!({"lon": 5.0, "lat": 47.0, "scale": 20_000_000})).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-map-tif-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("view.tif"); let read = |p: &std::path::Path| geotiff::read(std::io::Cursor::new(std::fs::read(p).unwrap()), "view").unwrap(); let out = s.run("share.exportMap", &json!({"path": path.display().to_string(), "width": 400, "height": 300})).unwrap(); assert_eq!((out["width"].as_u64(), out["height"].as_u64(), out["crs"].as_str()), (Some(400), Some(300), Some("EPSG:3857"))); let r = read(&path); // RGB: a drawn view is opaque. assert_eq!((r.width, r.height, r.crs, r.bands.len()), (400, 300, Crs::WebMercator, 3)); // Its grid is the view's: the centre in the middle, the camera's resolution a pixel. let cam = s.map().camera; let t = r.transform; assert!((t.pixel_width - cam.res).abs() < 1e-6 * cam.res && (t.pixel_height + cam.res).abs() < 1e-6 * cam.res, "{t:?}"); assert!((t.origin_x + 200.0 * cam.res - cam.center.x).abs() < 1e-3 && (t.origin_y - 150.0 * cam.res - cam.center.y).abs() < 1e-3); // Land and sea where they are: the pixel at a place inland differs from one at sea. let at = |r: &rustgis_data::raster::Raster, lon: f64, lat: f64| { let m = lonlat_to_merc(Coord::new(lon, lat)); let (c, row) = ( ((m.x - r.transform.origin_x) / r.transform.pixel_width) as usize, ((m.y - r.transform.origin_y) / r.transform.pixel_height) as usize, ); let i = row * r.width as usize + c; r.bands.iter().map(|b| b.data[i]).collect::>() }; let (land, sea) = (at(&r, 10.0, 50.0), at(&r, -20.0, 45.0)); assert_ne!(land, sea, "land {land:?}, sea {sea:?}"); // The same view exported twice is the same picture: a turned one is a different grid. s.run("map.setBearing", &json!({"bearing": 30})).unwrap(); let turned = dir.join("turned.tif"); let out = s.run("share.exportMap", &json!({"path": turned.display().to_string(), "width": 400, "height": 300})).unwrap(); let r2 = read(&turned); assert!(r2.width > 400 && r2.height > 300, "{} × {}", r2.width, r2.height); assert!(s.notices.iter().any(|n| n.text.contains("turned 30°")), "{out}"); // North up over the box: the same place lands on the same ground. assert_eq!(at(&r2, 10.0, 50.0), land); assert!((r2.transform.pixel_width - cam.res).abs() < 1e-6 * cam.res); // At 100 DPI the box isn't a whole number of pixels: the picture still covers all of it. let at100 = dir.join("turned-100.tif"); s.run("share.exportMap", &json!({"path": at100.display().to_string(), "width": 400, "height": 300, "dpi": 100})).unwrap(); let r3 = read(&at100); let (bw, bh) = s.map().camera.rotated_size(400.0, 300.0); let cell = r3.transform.pixel_width; assert!( f64::from(r3.width) * cell >= bw * cam.res - 1e-6 && f64::from(r3.height) * cell >= bh * cam.res - 1e-6, "{} × {}", r3.width, r3.height ); s.run("map.setBearing", &json!({"bearing": 0})).unwrap(); // `cog` writes a cloud-optimised one; it's for a GeoTIFF only. let cog = dir.join("view-cog.tif"); let out = s.run("share.exportMap", &json!({"path": cog.display().to_string(), "width": 400, "height": 300, "cog": true})).unwrap(); assert!(out["bytes"].as_u64().unwrap() > 0); // Written by the tiled writer: its tiling is in the answer, as Export Raster gives it. assert!(out["cog"]["overviews"].is_u64() && out["cog"]["bigTiff"] == false, "{out}"); assert_eq!(read(&cog).width, 400); let png = dir.join("view.png"); let e = s.run("share.exportMap", &json!({"path": png.display().to_string(), "cog": true})).unwrap_err(); assert!(e.contains("cog is for a GeoTIFF"), "{e}"); let e = s.run("share.exportMap", &json!({"path": cog.display().to_string(), "cog": "yes"})).unwrap_err(); assert!(e.contains("cog must be true or false"), "{e}"); // A view past 8000 points (no size given) is cut to its middle, and says so, as a PNG does; // one that fits says nothing. let n = s.notices.len(); s.run("share.exportMap", &json!({"path": path.display().to_string()})).unwrap(); assert!(!s.notices[n..].iter().any(|x| x.text.contains("cut to its middle"))); s.view_size = (9000.0, 300.0); for file in ["wide.tif", "wide.png"] { let n = s.notices.len(); s.run("share.exportMap", &json!({"path": dir.join(file).display().to_string()})).unwrap(); assert!(s.notices[n..].iter().any(|x| x.text.contains("9000 × 300 points") && x.text.contains("cut to its middle")), "{file}"); } // A size given draws the whole view, so nothing is cut. let n = s.notices.len(); s.run("share.exportMap", &json!({"path": dir.join("fit.tif").display().to_string(), "width": 4000})).unwrap(); assert!(!s.notices[n..].iter().any(|x| x.text.contains("cut to its middle") || x.text.contains("points is more than"))); // The whole 9000-point view in 4000 points: each pixel 2.25 of the camera's, the same centre. let fit = read(&dir.join("fit.tif")); let cam = s.map().camera; assert_eq!((fit.width, fit.height), (4000, 300)); assert!((fit.transform.pixel_width - 2.25 * cam.res).abs() < 1e-6 * cam.res, "{:?}", fit.transform); assert!((fit.transform.origin_x + 2000.0 * fit.transform.pixel_width - cam.center.x).abs() < 1e-3); // Square pixels, and the centre down the middle too. assert!((fit.transform.pixel_height + fit.transform.pixel_width).abs() < 1e-9 * cam.res, "{:?}", fit.transform); assert!((fit.transform.origin_y + 150.0 * fit.transform.pixel_height - cam.center.y).abs() < 1e-3); s.view_size = (400.0, 300.0); // A size given past the limit is drawn at the limit, and says so. let n = s.notices.len(); let out = s.run("share.exportMap", &json!({"path": dir.join("big.tif").display().to_string(), "width": 9000, "height": 150})).unwrap(); assert_eq!(out["width"], 8000); assert!( s.notices[n..].iter().any(|x| x.text.contains("a width of 9000 points") && x.text.contains("drawn at 8000")), "{:?}", &s.notices[n..] ); assert!(!s.notices[n..].iter().any(|x| x.text.contains("height of"))); // `fit` lowers the DPI to what fits 8192 pixels a side, and says so; one that fits stays. let fitted = |s: &mut Session, file: &str| { let n = s.notices.len(); let out = s.run("share.exportMap", &json!({"path": dir.join(file).display().to_string(), "dpi": 192, "fit": true})).unwrap(); let said = s.notices[n..].iter().find_map(|x| x.text.split("drawn at ").nth(1).and_then(|t| t.split(' ').next()).map(str::to_string)); (out, said) }; s.view_size = (1200.0, 800.0); assert_eq!(fitted(&mut s, "small.tif").1, None); assert_eq!(fitted(&mut s, "small.png").1, None); s.view_size = (5000.0, 3000.0); // 8192 × 96 / 5000 = 157.3: whole DPI, down. let (out, said) = fitted(&mut s, "wide.tif"); assert_eq!((said.as_deref(), out["width"].as_u64()), (Some("157"), Some(8177))); assert_eq!(fitted(&mut s, "wide.png").1.as_deref(), Some("157")); // Past 8000 points the view is clamped first: 16000 wide fits 98 DPI. s.view_size = (16000.0, 3000.0); assert_eq!(fitted(&mut s, "huge.tif").1.as_deref(), Some("98")); // A GeoTIFF of a turned view counts its box (3000 points square turned 45° is 4243); a PNG // is drawn turned in the view's own size. s.view_size = (3000.0, 3000.0); assert_eq!(fitted(&mut s, "square.tif").1, None); s.run("map.setBearing", &json!({"bearing": 45})).unwrap(); assert_eq!(fitted(&mut s, "square-turned.tif").1.as_deref(), Some("185")); assert_eq!(fitted(&mut s, "square-turned.png").1, None); // A box just past 8192 pixels (8192.2) is rounded out, as the GeoTIFF's canvas is: lowered to // 95 DPI, and written. s.view_size = (5792.76, 5792.76); let n = s.notices.len(); let out = s.run("share.exportMap", &json!({"path": dir.join("edge.tif").display().to_string(), "dpi": 96, "fit": true})).unwrap(); assert!(s.notices[n..].iter().any(|x| x.text.contains("drawn at 95 DPI")), "{out}"); s.run("map.setBearing", &json!({"bearing": 0})).unwrap(); // The app's download and clipboard copy (no size given) fit too: 5000 points at 192 DPI is // drawn at 157, and said. s.view_size = (5000.0, 3000.0); let n = s.notices.len(); let png = crate::cmd::share::map_png_fitted(&mut s, 192.0).unwrap(); assert_eq!(rustgis_render::decode_png(&png).map(|p| p.width()), Some(8177)); assert!(s.notices[n..].iter().any(|x| x.text.contains("drawn at 157 DPI"))); let n = s.notices.len(); s.view_size = (400.0, 300.0); crate::cmd::share::map_png_fitted(&mut s, 192.0).unwrap(); assert!(!s.notices[n..].iter().any(|x| x.text.contains("DPI") || x.text.contains("cut"))); // A view past 8000 points is cut to its middle, and said. s.view_size = (9000.0, 300.0); let n = s.notices.len(); let png = crate::cmd::share::map_png_fitted(&mut s, 96.0).unwrap(); assert_eq!(rustgis_render::decode_png(&png).map(|p| (p.width(), p.height())), Some((8000, 300))); assert!( s.notices[n..].iter().any(|x| x.text.contains("cut to its middle") && x.text.contains("share.exportMap")), "{:?}", &s.notices[n..] ); // A DPI below the least is drawn at 48 (export_view clamps it before the fit looks at it). s.view_size = (6000.0, 300.0); crate::cmd::share::map_png_fitted(&mut s, 30.0).unwrap(); s.view_size = (400.0, 300.0); let e = s.run("share.exportMap", &json!({"path": path.display().to_string(), "fit": "yes"})).unwrap_err(); assert!(e.contains("fit must be true or false"), "{e}"); // A size below 16 points, and a dpi outside 48 to 600, are drawn at the limit, and say so. let n = s.notices.len(); let out = s .run("share.exportMap", &json!({"path": dir.join("tiny.tif").display().to_string(), "width": 5, "height": 100, "dpi": 30})) .unwrap(); assert_eq!(out["width"], 8); let said: Vec<&str> = s.notices[n..].iter().map(|x| x.text.as_str()).collect(); assert!(said.iter().any(|t| t.contains("a width of 5 points is less than the 16")), "{said:?}"); assert!(said.iter().any(|t| t.contains("a dpi of 30 is outside 48 to 600, so it is drawn at 48")), "{said:?}"); assert!(!said.iter().any(|t| t.contains("height of")), "{said:?}"); // 600 DPI is 6.25 pixels a point, as the command says, not cut to 576. let fine = dir.join("fine.tif"); let out = s.run("share.exportMap", &json!({"path": fine.display().to_string(), "width": 100, "height": 100, "dpi": 600})).unwrap(); assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(625), Some(625))); // A PNG past the canvas's 8192 pixels a side is refused too, not cut off at its top left. let e = s.run("share.exportMap", &json!({"path": dir.join("big.png").display().to_string(), "width": 5000, "dpi": 192})).unwrap_err(); assert!(e.contains("10000") && e.contains("8192"), "{e}"); // Too many pixels a side is refused, saying how many. let e = s.run("share.exportMap", &json!({"path": path.display().to_string(), "width": 8000, "dpi": 192})).unwrap_err(); assert!(e.contains("16000") && e.contains("8192") && !e.contains("turned"), "{e}"); // A tilted map and the globe are refused, and write nothing. let none = dir.join("none.tif"); s.run("map.setPitch", &json!({"pitch": 40})).unwrap(); let e = s.run("share.exportMap", &json!({"path": none.display().to_string()})).unwrap_err(); assert!(e.contains("tilted 40°"), "{e}"); s.run("map.setPitch", &json!({"pitch": 0})).unwrap(); s.run("map.projection", &json!({"type": "globe"})).unwrap(); let e = s.run("share.exportMap", &json!({"path": none.display().to_string()})).unwrap_err(); assert!(e.contains("globe can't be saved as a GeoTIFF"), "{e}"); s.run("map.projection", &json!({"type": "mercator"})).unwrap(); // So is a map of another body, whose ground no Earth system holds. s.run("map.body", &json!({"name": "mars", "basemap": false})).unwrap(); let e = s.run("share.exportMap", &json!({"path": none.display().to_string()})).unwrap_err(); assert!(e.contains("a map of Mars can't be saved as a GeoTIFF"), "{e}"); assert!(!none.exists()); std::fs::remove_dir_all(&dir).ok(); } /// A GeoTIFF of a map in another projection is in that projection: an EPSG one by its code, /// another by its definition; one the GeoTIFF keys can't say is refused. #[test] fn export_map_to_geotiff_keeps_the_maps_projection() { use rustgis_data::raster::{Crs, geotiff}; let mut s = world(); s.map_mut().basemap = rustgis_map::Basemap::Blank; s.run("map.goToXY", &json!({"lon": 7.45, "lat": 46.95, "scale": 2_000_000})).unwrap(); let dir = std::env::temp_dir().join(format!("rustgis-map-tif-crs-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let read = |p: &std::path::Path| geotiff::read(std::io::Cursor::new(std::fs::read(p).unwrap()), "view").unwrap(); s.run("map.projection", &json!({"type": "EPSG:2056"})).unwrap(); let lv95 = dir.join("lv95.tif"); let out = s.run("share.exportMap", &json!({"path": lv95.display().to_string(), "width": 200, "height": 200})).unwrap(); assert_eq!(out["crs"], "EPSG:2056"); let r = read(&lv95); assert_eq!(r.crs, Crs::Epsg(2056)); // Bern's centre in LV95 metres (about 2 600 000, 1 200 000) is near the middle of the image. let (cx, cy) = (r.transform.origin_x + 100.0 * r.transform.pixel_width, r.transform.origin_y + 100.0 * r.transform.pixel_height); assert!((cx - 2_600_000.0).abs() < 5_000.0 && (cy - 1_200_000.0).abs() < 5_000.0, "{cx} {cy}"); // A local view (azimuthal equidistant about Bern) by its definition. s.run("map.projection", &json!({"type": "local:7.45,46.95"})).unwrap(); let local = dir.join("local.tif"); s.run("share.exportMap", &json!({"path": local.display().to_string(), "width": 200, "height": 200})).unwrap(); let r = read(&local); assert!(r.crs.custom_def().is_some_and(|d| d.proj4.contains("+proj=aeqd")), "{:?}", r.crs); // One the GeoTIFF keys can't say is refused before anything is drawn, saying what to do. s.run("map.projection", &json!({"type": "mollweide"})).unwrap(); let moll = dir.join("moll.tif"); let e = s.run("share.exportMap", &json!({"path": moll.display().to_string()})).unwrap_err(); assert!(e.contains("can't be described in a GeoTIFF") && e.contains("export a PNG"), "{e}"); assert!(!moll.exists()); std::fs::remove_dir_all(&dir).ok(); } /// A tile layer's pictures are in a GeoTIFF of the view where they lie, and its credits in the /// file's Copyright tag (#1561). #[test] fn export_map_to_geotiff_draws_tile_layers_and_keeps_their_credits() { use rustgis_data::raster::geotiff; let dir = std::env::temp_dir().join(format!("rustgis-map-tif-tiles-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let archive = dir.join("red.mbtiles"); let mut red = tiny_skia::Pixmap::new(256, 256).unwrap(); red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); let c = rusqlite::Connection::open(&archive).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Red'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '0'), ('attribution', '© Röd');", ) .unwrap(); c.execute("INSERT INTO tiles VALUES (0, 0, 0, ?1)", [red.encode_png().unwrap()]).unwrap(); drop(c); let mut s = Session::new(Project::default()); s.map_mut().basemap = rustgis_map::Basemap::Blank; s.view_size = (300.0, 200.0); s.run("layer.addData", &json!({"path": archive.display().to_string()})).unwrap(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 20.0, "scale": 50_000_000})).unwrap(); let path = dir.join("tiles.tif"); let out = s.run("share.exportMap", &json!({"path": path.display().to_string()})).unwrap(); // The answer has the credits whole; the tag holds ASCII, and the export says what it changed. assert_eq!(out["copyright"], "© Röd"); let r = geotiff::read(std::io::Cursor::new(std::fs::read(&path).unwrap()), "tiles").unwrap(); assert_eq!(r.copyright.as_deref(), Some("(c) R?d")); assert!( s.notices.iter().any(|n| n.text.contains("written with 1 © or ® as (c) or (R), 1 character as ? (the credits: \"© Röd\")")), "{:?}", s.notices ); // The tiles had all come: nothing says the picture has gaps. assert!(!s.notices.iter().any(|n| n.text.contains("export again once")), "{:?}", s.notices); // The middle pixel is the tile's red. let i = 100 * r.width as usize + 150; let px: Vec = r.bands.iter().map(|b| b.data[i]).collect(); assert_eq!(px, [255.0, 0.0, 0.0]); // ASCII credits say nothing; a © alone says it is written as (c). let n = s.notices.len(); s.map_mut().tiles[0].attribution = "(c) Red".into(); s.run("share.exportMap", &json!({"path": path.display().to_string()})).unwrap(); assert!(!s.notices[n..].iter().any(|n| n.text.contains("Copyright tag")), "{:?}", &s.notices[n..]); let n = s.notices.len(); s.map_mut().tiles[0].attribution = "© Red".into(); s.run("share.exportMap", &json!({"path": path.display().to_string()})).unwrap(); assert!(s.notices[n..].iter().any(|n| n.text.contains("written with 1 © or ® as (c) or (R) (the credits")), "{:?}", &s.notices[n..]); // A tile layer that can't be drawn (its archive gone) leaves a gap that would pass for data: // said, as a warning. let broken = dir.join("broken.mbtiles"); let c = rusqlite::Connection::open(&broken).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Broken'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '0'); INSERT INTO tiles VALUES (0, 0, 0, x'00010203');", ) .unwrap(); drop(c); s.map_mut().tiles.clear(); s.run("layer.addData", &json!({"path": broken.display().to_string()})).unwrap(); // Its file gone before the map is drawn: the layer points at an archive that isn't there. // (Not by deleting `broken.mbtiles`: the session holds it open, and Windows refuses to // delete an open file.) let url = s.map().tiles[0].url.clone(); let gone = url.strip_suffix("broken.mbtiles").map(|head| format!("{head}gone.mbtiles")); assert!(gone.is_some(), "{url}"); assert!(!dir.join("gone.mbtiles").exists()); s.map_mut().tiles[0].url = gone.unwrap_or_default(); let n = s.notices.len(); s.run("share.exportMap", &json!({"path": path.display().to_string()})).unwrap(); // Said as a tile layer that couldn't be drawn, or (where the reader is slower to give up) // tiles still on their way: either way a warning that parts are empty. assert!( s.notices[n..].iter().any(|n| n.severity == rustgis_data::diagnostics::Severity::Warning && n.text.contains("empty")), "{:?}", &s.notices[n..] ); std::fs::remove_dir_all(&dir).ok(); } /// Export Raster writes a tile layer alone: its tiles at the zoom asked, a pixel of the file for a /// pixel of a tile, on their Web Mercator grid, with their alpha and credits; a zoom past the /// service's enlarges its deepest tiles; too many tiles are refused, saying why (#1561). #[test] fn export_raster_puts_a_tile_layer_s_tiles_together() { use rustgis_data::raster::{ColorHint, Crs, geotiff}; let dir = std::env::temp_dir().join(format!("rustgis-tile-raster-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let archive = dir.join("quarters.mbtiles"); let tile = |c: Option<(u8, u8, u8)>| { let mut pm = tiny_skia::Pixmap::new(256, 256).unwrap(); if let Some((r, g, b)) = c { pm.fill(tiny_skia::Color::from_rgba8(r, g, b, 255)); } pm.encode_png().unwrap() }; let c = rusqlite::Connection::open(&archive).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Quarters'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '1'), ('attribution', 'Quarter Co');", ) .unwrap(); // Zoom 1, rows counted from the south: red north-west, green north-east, blue south-west and // nothing (see-through) south-east. for (x, y, colour) in [(0, 1, Some((255, 0, 0))), (1, 1, Some((0, 255, 0))), (0, 0, Some((0, 0, 255))), (1, 0, None)] { c.execute("INSERT INTO tiles VALUES (1, ?1, ?2, ?3)", rusqlite::params![x, y, tile(colour)]).unwrap(); } c.execute("INSERT INTO tiles VALUES (0, 0, 0, ?1)", [tile(Some((255, 255, 0)))]).unwrap(); drop(c); let mut s = Session::new(Project::default()); s.view_size = (300.0, 200.0); s.run("layer.addData", &json!({"path": archive.display().to_string()})).unwrap(); s.run("map.goToXY", &json!({"lon": 10.0, "lat": 20.0, "scale": 50_000_000})).unwrap(); let path = dir.join("world.tif"); let at = |p: &std::path::Path| p.display().to_string(); let world = json!([-180, -85.06, 180, 85.06]); let out = s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "bbox": world, "zoom": 1})).unwrap(); assert_eq!( (out["width"].as_u64(), out["height"].as_u64(), out["zoom"].as_u64(), out["tiles"].as_u64()), (Some(512), Some(512), Some(1), Some(4)) ); assert_eq!(out["copyright"], "Quarter Co"); let r = geotiff::read(std::io::Cursor::new(std::fs::read(&path).unwrap()), "world").unwrap(); assert_eq!((r.width, r.height, r.crs), (512, 512, Crs::WebMercator)); assert_eq!(r.color, ColorHint::Rgb { alpha: true }); assert_eq!(r.copyright.as_deref(), Some("Quarter Co")); let half = rustgis_geom::MERCATOR_R * std::f64::consts::PI; let t = r.transform; assert!((t.origin_x + half).abs() < 1e-6 && (t.origin_y - half).abs() < 1e-6, "{t:?}"); assert!((t.pixel_width - half / 256.0).abs() < 1e-6 && (t.pixel_height + half / 256.0).abs() < 1e-6, "{t:?}"); let px = |col: usize, row: usize| -> Vec { r.bands.iter().map(|b| b.data[row * 512 + col]).collect() }; assert_eq!(px(100, 100), [255.0, 0.0, 0.0, 255.0]); assert_eq!(px(400, 100), [0.0, 255.0, 0.0, 255.0]); assert_eq!(px(100, 400), [0.0, 0.0, 255.0, 255.0]); assert_eq!(px(400, 400)[3], 0.0); // The tiles had all come: nothing says it has gaps. assert!(!s.notices.iter().any(|n| n.text.contains("parts of it")), "{:?}", s.notices); // A box takes the pixels it touches, of the tile it is in. let out = s.run("share.exportRaster", &json!({"tiles": "Quarters", "path": at(&path), "bbox": [10, 10, 20, 20], "zoom": 1})).unwrap(); assert_eq!(out["tiles"], 1); let r = geotiff::read(std::io::Cursor::new(std::fs::read(&path).unwrap()), "box").unwrap(); assert!(r.bands[1].data.iter().all(|v| *v == 255.0) && r.bands[0].data.iter().all(|v| *v == 0.0)); // Past the service's deepest zoom its tiles are enlarged, and it says so. let n = s.notices.len(); let out = s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "bbox": world, "zoom": 2})).unwrap(); assert_eq!(out["width"], 1024); assert!(s.notices[n..].iter().any(|n| n.text.contains("tiles to zoom 1 only")), "{:?}", &s.notices[n..]); // Its zoom 0 is its own tile, 256 pixels for the world. let out = s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "bbox": world, "zoom": 0})).unwrap(); assert_eq!(out["width"], 256); // Too many pixels or tiles are refused, saying which zoom fits. let e = s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "bbox": [0, 0, 10, 10], "zoom": 12})).unwrap_err(); assert!(e.contains("more than the 1024 an export fetches") || e.contains("8192"), "{e}"); assert!(e.contains("fits)"), "{e}"); // The tile layer picked in the Layers pane is the one written, at the zoom the view shows. s.run("map.selectTiles", &json!({"index": 0})).unwrap(); let out = s.run("share.exportRaster", &json!({"path": at(&path)})).unwrap(); assert!(out["zoom"].as_u64().is_some_and(|z| z <= 1), "{out}"); // A raster layer named beside the tile layer is refused, not dropped. let e = s.run("share.exportRaster", &json!({"tiles": 0, "layer": "Cities", "path": at(&path)})).unwrap_err(); assert!(e.contains("name one layer"), "{e}"); // Named wrongly, or with a band, it is refused. assert!(s.run("share.exportRaster", &json!({"tiles": "Nowhere", "path": at(&path)})).unwrap_err().contains("no tile layer 'Nowhere'")); assert!(s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "band": 1})).is_err()); assert!(s.run("share.exportRaster", &json!({"tiles": 0, "path": at(&path), "bbox": [5, 0, 1, 1]})).is_err()); // Past 1024 tiles within 8192 pixels a side: refused for the tiles, with a zoom that fits. let deep = dir.join("deep.mbtiles"); let c = rusqlite::Connection::open(&deep).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Deep'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '20');", ) .unwrap(); c.execute("INSERT INTO tiles VALUES (0, 0, 0, ?1)", [tile(Some((9, 9, 9)))]).unwrap(); drop(c); s.run("layer.addData", &json!({"path": at(&deep)})).unwrap(); let d = s.map().tiles.iter().position(|t| t.name.eq_ignore_ascii_case("deep")).unwrap(); let e = s.run("share.exportRaster", &json!({"tiles": d, "path": at(&path), "bbox": [0.01, 0.01, 1.409, 1.409], "zoom": 13})).unwrap_err(); assert!(e.contains("1089 tiles, more than the 1024"), "{e}"); assert!(e.contains("(zoom 12 fits)"), "{e}"); // A bbox with an element that isn't a number is refused, not read without it. assert!(s.run("share.exportRaster", &json!({"tiles": d, "path": at(&path), "bbox": [10, null, 20, 30, 40]})).is_err()); // A raster layer is written whole: a box or zoom for it is refused, not dropped. s.run("layer.addData", &json!({"path": at(&path)})).unwrap(); let raster = s.map().layers.iter().find(|l| l.is_raster()).map(|l| l.name.clone()).unwrap(); let e = s.run("share.exportRaster", &json!({"layer": raster, "path": at(&dir.join("r.tif")), "bbox": [0, 0, 1, 1]})).unwrap_err(); assert!(e.contains("for a tile layer"), "{e}"); // Lossy both ways: a box where nothing is drawn (the see-through quarter) says so… let warned = |s: &Session, n: usize, what: &str| { s.notices[n..].iter().any(|x| x.severity == rustgis_data::diagnostics::Severity::Warning && x.text.contains(what)) }; let n = s.notices.len(); s.run("share.exportRaster", &json!({"tiles": "Quarters", "path": at(&path), "bbox": [10, -20, 20, -10], "zoom": 1})).unwrap(); assert!(warned(&s, n, "drew nothing in the box at zoom 1"), "{:?}", &s.notices[n..]); // …and so does a tile that can't be drawn, with how many blocks and errors. let broken = dir.join("broken.mbtiles"); let c = rusqlite::Connection::open(&broken).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Broken'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '0'); INSERT INTO tiles VALUES (0, 0, 0, x'00010203');", ) .unwrap(); drop(c); s.run("layer.addData", &json!({"path": at(&broken)})).unwrap(); let i = s.map().tiles.iter().position(|t| t.name.eq_ignore_ascii_case("broken")).unwrap(); // A tile it can't read is a gap, said with how many blocks and tiles, and why. let n = s.notices.len(); s.run("share.exportRaster", &json!({"tiles": i, "path": at(&path), "bbox": world, "zoom": 0})).unwrap(); assert!( warned(&s, n, "in 1 of its 1 block(s), broken couldn't be drawn whole (1 tile(s) couldn't be fetched or read)"), "{:?}", &s.notices[n..] ); // Over two blocks, each tile that fails is counted once, the one on their edge too: every // tile of this archive fails, so as many fail as the export fetches. let bad = dir.join("bad.mbtiles"); let c = rusqlite::Connection::open(&bad).unwrap(); c.execute_batch( "CREATE TABLE metadata (name text, value text); CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob); INSERT INTO metadata VALUES ('name', 'Bad'), ('format', 'png'), ('minzoom', '0'), ('maxzoom', '4');", ) .unwrap(); for x in 0..16 { for y in 0..16 { c.execute("INSERT INTO tiles VALUES (4, ?1, ?2, x'00010203')", [x, y]).unwrap(); } } drop(c); s.run("layer.addData", &json!({"path": at(&bad)})).unwrap(); let b = s.map().tiles.iter().position(|t| t.name.eq_ignore_ascii_case("bad")).unwrap(); let n = s.notices.len(); let out = s.run("share.exportRaster", &json!({"tiles": b, "path": at(&path), "bbox": [-170, -60, 50, 60], "zoom": 4})).unwrap(); assert!(out["width"].as_u64().is_some_and(|w| w > 2048), "{out}"); // 11 columns (pixels 113 to 2617 of 4096) by 8 rows (1189 to 2907) of 256-pixel tiles. let fetched = out["tiles"].as_u64().unwrap(); assert_eq!(fetched, 88); assert!( warned(&s, n, &format!("in 2 of its 2 block(s), Bad couldn't be drawn whole ({fetched} tile(s)")) || warned(&s, n, &format!("in 2 of its 2 block(s), bad couldn't be drawn whole ({fetched} tile(s)")), "{:?}", &s.notices[n..] ); // Its archive gone: refused, or written with the gap said, never quietly. let i = s.map().tiles.iter().position(|t| t.name.eq_ignore_ascii_case("broken")).unwrap(); let gone = s.map().tiles[i].url.replace("broken", "gone"); s.map_mut().tiles[i].url = gone; let n = s.notices.len(); if s.run("share.exportRaster", &json!({"tiles": i, "path": at(&path), "bbox": world, "zoom": 0})).is_ok() { assert!(warned(&s, n, "couldn't be drawn whole") || warned(&s, n, "hadn't come after the wait"), "{:?}", &s.notices[n..]); } std::fs::remove_dir_all(&dir).ok(); } /// A diagram on aggregated points or extruded polygons says when it won't show, once, naming the /// layer, whichever of the two is set second; a diagram on a plain layer says nothing of the kind /// (#1596). #[test] fn a_diagram_that_wont_show_says_why() { let mut s = world(); // The diagram notices since `mark` are exactly one, about `layer`'s diagram for `why`. let said = |s: &Session, mark: u64, layer: &str, why: &str| { let about: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).filter(|t| t.contains("its diagram")).collect(); about.len() == 1 && about.iter().all(|t| t.contains(&format!("Layer '{layer}'")) && t.contains(why)) }; let any = |s: &Session, mark: u64| s.notices_since(mark).iter().any(|n| n.text.contains("its diagram")); let (binned, tilted) = ("aggregated into bins", "when a flat map is tilted"); // Plain layers: no notice. let mark = s.notice_mark(); s.run("layer.diagram", &json!({"layer": "Cities", "kind": "pie", "fields": ["pop_max"], "size": 8})).unwrap(); s.run("layer.diagram", &json!({"layer": "Countries", "kind": "pie", "fields": ["pop_est"], "size": 8})).unwrap(); assert!(!any(&s, mark), "{:?}", s.notices_since(mark)); // Aggregation after the diagram, and a diagram after aggregation. let mark = s.notice_mark(); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "hexagon"})).unwrap(); assert!(said(&s, mark, "Cities", binned), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.diagram", &json!({"layer": "Cities", "kind": "bar", "fields": ["pop_max"], "size": 8})).unwrap(); assert!(said(&s, mark, "Cities", binned), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "none"})).unwrap(); assert!(!any(&s, mark), "aggregation off: {:?}", s.notices_since(mark)); assert!(s.map().layer_by_name("Cities").is_some_and(|l| l.diagram.is_some() && l.aggregation.is_none()), "binning off, diagram kept"); // Extrusion: not shown in a tilted flat map. let mark = s.notice_mark(); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "height", "height": 1000})).unwrap(); assert!(said(&s, mark, "Countries", tilted), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.diagram", &json!({"layer": "Countries", "kind": "pie", "fields": ["pop_est"], "size": 8})).unwrap(); assert!(said(&s, mark, "Countries", tilted), "{:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "none"})).unwrap(); assert!(!any(&s, mark), "extrusion off: {:?}", s.notices_since(mark)); assert!( s.map().layer_by_name("Countries").is_some_and(|l| l.diagram.is_some() && l.extrusion.is_none()), "extrusion off, diagram kept" ); // A layer's first diagram, set after the effect, says so too. s.run("layer.clearDiagram", &json!({"layer": "Cities"})).unwrap(); let mark = s.notice_mark(); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "square"})).unwrap(); assert!(!any(&s, mark), "no diagram yet: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.diagram", &json!({"layer": "Cities", "kind": "pie", "fields": ["pop_max"], "size": 8})).unwrap(); assert!(said(&s, mark, "Cities", binned), "{:?}", s.notices_since(mark)); s.run("layer.clearDiagram", &json!({"layer": "Countries"})).unwrap(); let mark = s.notice_mark(); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "height", "height": 1000})).unwrap(); assert!(!any(&s, mark), "no diagram yet: {:?}", s.notices_since(mark)); let mark = s.notice_mark(); s.run("layer.diagram", &json!({"layer": "Countries", "kind": "pie", "fields": ["pop_est"], "size": 8})).unwrap(); assert!(said(&s, mark, "Countries", tilted), "{:?}", s.notices_since(mark)); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "none"})).unwrap(); // On the globe too: the map may be made flat and tilted later, and the notice says the globe // shows the chart. s.map_mut().projection = rustgis_map::Projection::Globe; let mark = s.notice_mark(); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "height", "height": 1000})).unwrap(); assert!(said(&s, mark, "Countries", "on the globe"), "globe: {:?}", s.notices_since(mark)); } /// A project or layer file opened with a diagram on aggregated points or extruded polygons says /// so once per such layer, as setting it does; one with plain diagrams says nothing of the kind /// (#1596). #[test] fn opening_a_diagram_that_wont_show_says_why() { let about = |s: &Session| -> Vec { s.notices.iter().map(|n| n.text.clone()).filter(|t| t.contains("the diagram")).collect() }; let mut s = world(); s.run("layer.diagram", &json!({"layer": "Cities", "kind": "pie", "fields": ["pop_max"], "size": 8})).unwrap(); s.run("layer.diagram", &json!({"layer": "Countries", "kind": "pie", "fields": ["pop_est"], "size": 8})).unwrap(); let clean = serde_json::to_string(&s.project).unwrap(); let quiet = Session::new(Project::from_json(&clean).unwrap()); assert!(about(&quiet).is_empty(), "{:?}", about(&quiet)); s.run("layer.aggregation", &json!({"layer": "Cities", "shape": "hexagon"})).unwrap(); s.run("layer.extrusion", &json!({"layer": "Countries", "type": "height", "height": 1000})).unwrap(); let hidden = serde_json::to_string(&s.project).unwrap(); let check = |said: Vec| { assert_eq!(said.len(), 2, "{said:?}"); assert!(said.iter().any(|t| t.starts_with("1 layer(s) ('Cities')") && t.contains("aggregated into bins")), "{said:?}"); assert!(said.iter().any(|t| t.starts_with("1 layer(s) ('Countries')") && t.contains("when a flat map is tilted")), "{said:?}"); }; check(about(&Session::new(Project::from_json(&hidden).unwrap()))); let mut reopened = Session::new(Project::default()); reopened.run("project.openText", &json!({"text": hidden})).unwrap(); check(about(&reopened)); // A layer file: one notice for its layer. let cities = s.map().layer_by_name("Cities").map(|l| serde_json::to_string(l).unwrap()).unwrap(); let mut added = Session::new(Project::default()); crate::cmd::layer::add_layer_text(&mut added, &cities).unwrap(); let said = about(&added); assert!(said.len() == 1 && said[0].contains("aggregated into bins"), "{said:?}"); // A map file: both layers; one with plain diagrams says nothing. let map = |s: &Session| serde_json::to_string(s.map()).unwrap(); let mut imported = Session::new(Project::default()); imported.run("map.import", &json!({"text": map(&s)})).unwrap(); check(about(&imported)); let plain = Session::new(Project::from_json(&clean).unwrap()); let mut imported = Session::new(Project::default()); imported.run("map.import", &json!({"text": map(&plain)})).unwrap(); assert!(about(&imported).is_empty(), "{:?}", about(&imported)); // A style import takes no diagrams, so it says nothing about them. let mut styled = Session::new(Project::default()); styled.run("style.import", &json!({"text": cities})).unwrap(); styled.run("style.import", &json!({"text": map(&s)})).unwrap(); styled.run("style.import", &json!({"text": hidden})).unwrap(); assert!(about(&styled).is_empty(), "{:?}", about(&styled)); // A file whose label audit can't read it (a sequence-form label class) still says so. let mut odd: Value = serde_json::from_str(&cities).unwrap(); odd["labels"]["classes"] = json!([["Extra", true, null, "pop_max", 10, [0, 0, 0, 255], false, false]]); let mut added = Session::new(Project::default()); crate::cmd::layer::add_layer_text(&mut added, &odd.to_string()).unwrap(); assert_eq!(about(&added).len(), 1, "{:?}", about(&added)); let mut odd_map: Value = serde_json::from_str(&map(&s)).unwrap(); for l in odd_map["layers"].as_array_mut().unwrap() { l["labels"]["classes"] = json!([["Extra", true, null, "pop_max", 10, [0, 0, 0, 255], false, false]]); } let mut imported = Session::new(Project::default()); imported.run("map.import", &json!({"text": odd_map.to_string()})).unwrap(); check(about(&imported)); // An offline snapshot: quiet with the project, said when it opens as a map. let mut offline = Session::new(Project::from_json(&clean).unwrap()); let snapshot = s.map().clone(); offline.project.offline_areas.push(crate::project::OfflineArea { name: "Trip".into(), map: snapshot.name.clone(), extent: rustgis_geom::Extent::new(-10.0, -10.0, 10.0, 10.0), created: 0.0, synced: 0.0, data: snapshot, }); let mut reread = Session::new(Project::from_json(&serde_json::to_string(&offline.project).unwrap()).unwrap()); assert!(about(&reread).is_empty(), "{:?}", about(&reread)); reread.run("offline.open", &json!({"name": "Trip"})).unwrap(); check(about(&reread)); // A project's map past the 100 `map.import` takes gives no notice there. let mut wide = Session::new(Project::from_json(&clean).unwrap()).project; let plain_map = wide.maps[0].clone(); let hidden_map = s.map().clone(); wide.maps = (0..100).map(|_| plain_map.clone()).chain(std::iter::once(hidden_map)).collect(); let mut imported = Session::new(Project::default()); imported.run("map.import", &json!({"text": serde_json::to_string(&wide).unwrap()})).unwrap(); assert!(about(&imported).is_empty(), "{:?}", about(&imported)); // Many such layers: one notice, counting them and naming the first three. let mut many = s.project.clone(); let template = many.maps[0].layer_by_name("Cities").cloned().unwrap(); many.maps[0].layers = (0..25) .map(|i| { let mut l = template.clone(); l.id = 1000 + i; l.name = format!("Cities {i}"); l }) .collect(); let said = about(&Session::new(Project::from_json(&serde_json::to_string(&many).unwrap()).unwrap())); assert!(said.len() == 1 && said[0].starts_with("25 layer(s) ('Cities 0', 'Cities 1', 'Cities 2' and 22 more)"), "{said:?}"); } #[test] fn bundled_ca_roots_are_said_once_a_session() { // #1591: a Linux system without CA certificates makes native HTTPS trust the bundled roots. let said = |s: &Session| s.notices.iter().filter(|n| n.text.contains("Mozilla roots")).count(); let mut s = Session::new(Project::default()); s.say_bundled_roots(false); assert_eq!(said(&s), 0); s.say_bundled_roots(true); s.say_bundled_roots(true); assert_eq!(said(&s), 1); assert!(s.notices.iter().all(|n| !n.error)); // Another session (Python can hold several) says it too. let mut other = Session::new(Project::default()); other.say_bundled_roots(true); assert_eq!(said(&other), 1); } /// `raster.spectral` reads several points (one off the raster said, its values null), and every /// answer gives the bands' wavelengths in nanometres, read back from the GeoTIFF's GDAL metadata; /// a raster whose file gives none has none. #[test] fn spectral_profiles_at_points_with_wavelengths() { use base64::Engine; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let tiff = |wavelengths: bool| { let gt = GeoTransform { origin_x: 10.0, origin_y: 45.0, pixel_width: 0.01, pixel_height: -0.01 }; // Red, green, blue: 30 + i, 20 + i, 10 + i at pixel i. let bands = (0..3).map(|b| (0..16).map(|i| (30 - b * 10 + i) as f32).collect()).collect(); let mut r = Raster::new("scene", 4, 4, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt8").unwrap(); if wavelengths { for ((band, nm), name) in r.bands.iter_mut().zip([665.0, 560.0, 490.0]).zip(["Red", "Green", "Blue"]) { band.wavelength = Some(nm); band.name = name.to_string(); } } base64::engine::general_purpose::STANDARD.encode(geotiff::write(&r, 0).unwrap()) }; let mut s = Session::new(Project::default()); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": tiff(true)})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "plain.tif", "data": tiff(false)})).unwrap(); let mark = s.notice_mark(); let v = s.run("raster.spectral", &json!({"layer": "scene", "points": [[10.005, 44.995], [10.015, 44.985], [20.0, 10.0]]})).unwrap(); assert_eq!(v["wavelengths"], json!([665.0, 560.0, 490.0]), "{v}"); assert_eq!(v["wavelengthUnit"], "nm"); assert_eq!(v["bands"].as_array().map(Vec::len), Some(3)); assert_eq!(v["profiles"][0]["pixel"], json!([0, 0])); assert_eq!(v["profiles"][0]["values"], json!([30.0, 20.0, 10.0])); assert_eq!(v["profiles"][1]["pixel"], json!([1, 1])); assert_eq!(v["profiles"][1]["values"], json!([35.0, 25.0, 15.0])); assert_eq!(v["profiles"][2]["pixel"], Value::Null); assert_eq!(v["profiles"][2]["values"], json!([null, null, null])); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(said, ["scene: 1 of 3 points fall outside the raster: their values are null"]); // All inside: nothing said. let mark = s.notice_mark(); s.run("raster.spectral", &json!({"layer": "scene", "points": [[10.005, 44.995]]})).unwrap(); assert!(s.notices_since(mark).is_empty()); // One point and a zone give the wavelengths too; a file without them gives none. let one = s.run("raster.spectral", &json!({"layer": "scene", "lon": 10.005, "lat": 44.995})).unwrap(); assert_eq!(one["wavelengths"], json!([665.0, 560.0, 490.0])); assert_eq!(one["values"], json!([30.0, 20.0, 10.0])); // A resampled raster keeps its bands' names and wavelengths. s.run("raster.process", &json!({"layer": "scene", "operation": "resample", "factor": 2, "name": "half"})).unwrap(); let half = s.run("raster.spectral", &json!({"layer": "half", "lon": 10.005, "lat": 44.995})).unwrap(); assert_eq!(half["wavelengths"], json!([665.0, 560.0, 490.0])); assert_eq!((&one["bands"], &half["bands"]), (&json!(["Red", "Green", "Blue"]), &json!(["Red", "Green", "Blue"])), "{half}"); let plain = s.run("raster.spectral", &json!({"layer": "plain", "points": [[10.005, 44.995]]})).unwrap(); assert!(plain.get("wavelengths").is_none(), "{plain}"); // Points read before one point: the point left out is said. let mark = s.notice_mark(); let both = s.run("raster.spectral", &json!({"layer": "scene", "lon": 10.015, "lat": 44.985, "points": [[10.005, 44.995]]})).unwrap(); assert_eq!(both["profiles"][0]["pixel"], json!([0, 0])); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(said, ["scene: lon and lat left out: points are read"]); // A zone is read before both, and says so. let ring = "[10.001, 44.999], [10.019, 44.999], [10.019, 44.981], [10.001, 44.981], [10.001, 44.999]"; let zone = format!(r#"{{"type":"Feature","properties":{{}},"geometry":{{"type":"Polygon","coordinates":[[{ring}]]}}}}"#); s.run("layer.addDataText", &json!({"fileName": "zone.geojson", "text": zone})).unwrap(); for (extra, left) in [ (json!({"lon": 10.005, "lat": 44.995}), "lon and lat"), (json!({"points": [[10.005, 44.995]]}), "points"), (json!({"points": [[10.005, 44.995]], "lon": 10.005}), "points, lon and lat"), ] { let mut params = json!({"layer": "scene", "zone": "zone"}); params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); let mark = s.notice_mark(); let v = s.run("raster.spectral", ¶ms).unwrap(); assert!(v["pixels"].as_u64().is_some_and(|n| n > 0), "{v}"); assert_eq!((&v["wavelengths"], &v["wavelengthUnit"]), (&json!([665.0, 560.0, 490.0]), &json!("nm")), "{v}"); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(said, [format!("scene: {left} left out: the zone's mean is read")]); } // A run that fails says nothing of what it left out. let mark = s.notice_mark(); s.run("raster.spectral", &json!({"layer": "scene", "lon": 10.005, "lat": 44.995, "points": [[20.0, 10.0]]})).unwrap_err(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert_eq!(said, ["the point is outside the raster"], "only the error is said"); // Points that can't be read are errors. for (points, error) in [ (json!([[20.0, 10.0], [30.0, 10.0]]), "every point is outside the raster"), (json!([[20.0, 10.0]]), "the point is outside the raster"), (json!([]), "points is empty"), (json!("10,45"), "points is a list"), (json!([[10.0, 45.0], [10.0]]), "point 2 isn't [lon, lat]"), (json!([[10.0, "45"]]), "point 1 isn't [lon, lat]"), (json!(vec![[10.005, 44.995]; 1001]), "at most 1000"), ] { let e = s.run("raster.spectral", &json!({"layer": "scene", "points": points})).unwrap_err(); assert!(e.contains(error), "{e}"); } } #[test] fn area_display_expressions_survive_refused_geometry_edits() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType}; use rustgis_map::color::Ramp; use rustgis_map::symbology::Renderer; for config in 0..6 { let mut data = Dataset::new("Area display", GeometryType::Polygon, vec![]); data.push( Some(Geometry::Polygon(vec![vec![vec![ Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 0.0), ]]])), vec![], ); let mut session = Session::new(Project::default()); let id = session.map_mut().add_dataset(data); let layer = session.map_mut().layer_mut(id).unwrap(); match config { 0 => layer.definition_query = Some("$area > 0".into()), 1 => layer.display_filters.push(rustgis_map::effects::DisplayFilter { name: "Area".into(), where_: "$area > 0".into(), min_scale: None, max_scale: None, }), 2 => layer.renderer = Renderer::unique_by(&layer.dataset, &[], "$area", Ramp::default(), layer.renderer.base_symbol()).unwrap(), 3 => layer.symbol_by.size = Some("$area".into()), 4 => layer.labels.default.by.text = Some("$area".into()), _ => layer.labels.default.where_ = Some("$area > 0".into()), } let result = session.run("edit.addFeature", &json!({"layer":"Area display","geometry":{"type":"Polygon","coordinates":[[[-180,90],[180,90],[-90,-90],[90,-80],[-180,90]]]}})); assert!(result.is_err_and(|error| error.to_string().contains("area is ambiguous")), "configuration {config}"); assert_eq!(session.map().layer(id).unwrap().dataset.features.len(), 1); assert_eq!(session.map().layer(id).unwrap().visible_rows(), [true]); session .run( "edit.addFeature", &json!({"layer":"Area display","geometry":{"type":"Polygon","coordinates":[[[2,0],[3,0],[3,1],[2,0]]]}}), ) .unwrap(); assert_eq!(session.map().layer(id).unwrap().dataset.features.len(), 2); } } #[test] #[cfg(not(target_arch = "wasm32"))] fn reload_streamed_unknown_geometry_gets_a_visible_default_and_known_geometry_keeps_style() { use rustgis_data::GeometryType; use rustgis_map::{Renderer, Symbol}; for (known, styled) in [(false, false), (false, true), (true, true)] { let dir = std::env::temp_dir().join(format!("rustgis-reload-empty-stream-{}-{known}-{styled}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("points.geojson"); let points = json!({ "type": "FeatureCollection", "features": [{ "type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]} }] }); let initial = if known { points.clone() } else { json!({"type": "FeatureCollection", "features": []}) }; std::fs::write(&path, initial.to_string()).unwrap(); let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap()["layer"].as_u64().unwrap() as u32; let layer = s.map_mut().layer_mut(id).unwrap(); assert!(layer.is_streamed()); assert_eq!(layer.dataset.geometry_type, if known { GeometryType::Point } else { GeometryType::None }); let mut custom = Symbol::default_for(layer.dataset.geometry_type, id); custom.size = 31.0; if styled { layer.renderer = Renderer::Single(custom); } let revision = layer.replacement_revision(); std::fs::write(&path, points.to_string()).unwrap(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); let layer = s.map().layer(id).unwrap(); assert_eq!(layer.dataset.geometry_type, GeometryType::Point); assert_ne!(layer.replacement_revision(), revision); let expected = if styled { custom } else { Symbol::default_for(GeometryType::Point, id) }; assert_eq!(layer.renderer, Renderer::Single(expected)); assert!(expected.size > 0.0); std::fs::remove_dir_all(dir).unwrap(); } } /// A layer copied or added from a layer file gets a new id but keeps its renderer: an untouched /// no-geometry default seeded by its old id still becomes a visible default on reload. #[test] #[cfg(not(target_arch = "wasm32"))] fn reload_gives_a_visible_default_to_an_untyped_layer_whose_default_came_from_another_id() { use rustgis_data::GeometryType; use rustgis_map::{Renderer, Symbol}; let dir = std::env::temp_dir().join(format!("rustgis-reload-reid-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("points.geojson"); std::fs::write(&path, json!({"type": "FeatureCollection", "features": []}).to_string()).unwrap(); let mut s = Session::new(Project::default()); let id = s.run("layer.addData", &json!({"path": path, "stream": true})).unwrap()["layer"].as_u64().unwrap() as u32; let old_seed = id + 3; s.map_mut().layer_mut(id).unwrap().renderer = Renderer::Single(Symbol::default_for(GeometryType::None, old_seed)); let points = json!({"type": "FeatureCollection", "features": [{ "type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]} }]}); std::fs::write(&path, points.to_string()).unwrap(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); let layer = s.map().layer(id).unwrap(); assert_eq!(layer.renderer, Renderer::Single(Symbol::default_for(GeometryType::Point, old_seed))); std::fs::remove_dir_all(dir).unwrap(); } #[test] #[cfg(not(target_arch = "wasm32"))] fn reload_editable_unknown_geometry_gets_a_visible_default_and_known_geometry_keeps_style() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType, raster::Crs, source::gpkg}; use rustgis_map::{Renderer, Symbol}; for (known, styled) in [(false, false), (false, true), (true, true)] { let dir = std::env::temp_dir().join(format!("rustgis-reload-empty-editable-{}-{known}-{styled}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("points.gpkg"); let mut data = Dataset::new("points", GeometryType::Point, vec![]); if known { data.push(Some(Geometry::Point(Coord::new(1.0, 2.0))), vec![]); } gpkg::write_in(&data, &path, Crs::LonLat).unwrap(); if !known { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute("UPDATE gpkg_geometry_columns SET geometry_type_name = 'GEOMETRY' WHERE table_name = 'points'", []).unwrap(); } let mut s = Session::new(Project::default()); let id = s.run("gpkg.addLayer", &json!({"path": path, "table": "points"})).unwrap()["layer"].as_u64().unwrap() as u32; let layer = s.map_mut().layer_mut(id).unwrap(); assert_eq!(layer.dataset.geometry_type, if known { GeometryType::Point } else { GeometryType::None }); let mut custom = Symbol::default_for(layer.dataset.geometry_type, id); custom.size = 31.0; if styled { layer.renderer = Renderer::Single(custom); } let revision = layer.replacement_revision(); data.push(Some(Geometry::Point(Coord::new(3.0, 4.0))), vec![]); let replacement = dir.join("replacement.gpkg"); gpkg::write_in(&data, &replacement, Crs::LonLat).unwrap(); std::fs::copy(replacement, &path).unwrap(); s.run("layer.reloadData", &json!({"layer": id})).unwrap(); let layer = s.map().layer(id).unwrap(); assert_eq!(layer.dataset.geometry_type, GeometryType::Point); assert_ne!(layer.replacement_revision(), revision); let expected = if styled { custom } else { Symbol::default_for(GeometryType::Point, id) }; assert_eq!(layer.renderer, Renderer::Single(expected)); assert!(expected.size > 0.0); std::fs::remove_dir_all(dir).unwrap(); } } #[test] #[cfg(not(target_arch = "wasm32"))] fn reload_failure_guidance_distinguishes_editable_geopackages_from_linked_files() { use rustgis_data::{Coord, Dataset, Geometry, GeometryType, io, raster::Crs, source::gpkg}; let dir = std::env::temp_dir().join(format!("rustgis-reload-failure-guidance-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); for editable in [true, false] { let path = dir.join(format!("points-{editable}.gpkg")); let mut original = Dataset::new("points", GeometryType::Point, vec![]); original.push(Some(Geometry::Point(Coord::new(1.0, 2.0))), vec![]); gpkg::write_in(&original, &path, Crs::LonLat).unwrap(); let mut s = Session::new(Project::default()); let command = if editable { "gpkg.addLayer" } else { "layer.addData" }; let id = s.run(command, &json!({"path": path, "table": "points", "stream": true})).unwrap()["layer"].as_u64().unwrap() as u32; assert_eq!(s.map().layer(id).unwrap().gpkg.is_some(), editable); let before = serde_json::to_value(s.map().layer(id).unwrap()).unwrap(); let mut replacement = Dataset::new("points", GeometryType::Polyline, vec![]); replacement.push(Some(Geometry::Line(vec![vec![Coord::new(1.0, 2.0), Coord::new(3.0, 4.0)]])), vec![]); let target = io::GpkgTable { mode: io::GpkgMode::ReplaceTable, name: Some("points".into()) }; gpkg::write_table_in(&replacement, &path, Crs::LonLat, &target).unwrap(); let error = s.run("layer.reloadData", &json!({"layer": id})).unwrap_err(); assert!(error.contains("reload failed; existing data and configuration retained"), "{error}"); if editable { assert!(error.contains("Correct the file and retry Reload Data, or add the table again with Add Data."), "{error}"); assert!(!error.contains("Repair Data Source"), "{error}"); } else { assert!(error.contains("Retry Reload Data or use Repair Data Source."), "{error}"); } assert_eq!(serde_json::to_value(s.map().layer(id).unwrap()).unwrap(), before); } std::fs::remove_dir_all(dir).unwrap(); } #[cfg(not(target_arch = "wasm32"))] mod archive_render_revision; /// `gp.threads` sets the shared count every place that starts threads for CPU work takes (#1562), /// says it, the default's and the cores, refuses what it can't take, and `null` goes back to the /// default. No undo step and no change to the project: it is the process's, not the project's. /// In a process of its own, since the setting holds for the whole process. #[test] fn the_thread_setting_is_the_whole_process() { #[cfg(not(target_arch = "wasm32"))] if isolate_process_test("tests::the_thread_setting_is_the_whole_process") { return; } let mut s = Session::new(Project::default()); let cores = rustgis_data::threads::cores(); let default = cores.min(rustgis_data::threads::DEFAULT_MAX); let t = s.run("gp.threads", &json!({})).unwrap(); assert_eq!(t, json!({"threads": null, "max": default, "default": default, "cores": cores})); let t = s.run("gp.threads", &json!({"threads": 2})).unwrap(); assert_eq!((t["threads"].clone(), t["max"].clone(), t["default"].clone()), (json!(2), json!(cores.min(2)), json!(default)), "{t}"); assert_eq!(rustgis_data::threads::max(), cores.min(2), "the count everything takes follows"); // `gp.env` says the threads it gives with the other limits (in another session: `gp.env` is // a project command, with an undo step of its own). let env = Session::new(Project::default()).run("gp.env", &json!({})).unwrap(); assert_eq!((env["limits"]["threads"].clone(), env["limits"]["cores"].clone()), (json!(cores.min(2)), json!(cores)), "{env}"); // Above the cores: all of them, the setting kept as given. let t = s.run("gp.threads", &json!({"threads": 1024})).unwrap(); assert_eq!((t["threads"].clone(), t["max"].clone()), (json!(1024), json!(cores)), "{t}"); for bad in [json!(0), json!(1025), json!(-1), json!(2.5), json!("4")] { let e = s.run("gp.threads", &json!({"threads": bad})).unwrap_err(); assert!(e.contains("1 to 1024"), "{bad}: {e}"); assert_eq!(rustgis_data::threads::setting(), Some(1024), "a refused value changes nothing"); } assert_eq!(s.run("gp.threads", &json!({"threads": null})).unwrap()["threads"], Value::Null); assert_eq!(rustgis_data::threads::max(), default); assert!(!s.dirty() && !s.can_undo(), "nothing to undo, nothing to save"); } /// Only the user changes the thread setting: the assistant asks, and agents, plugins and imported /// items can't (`Reserved`), since it reaches every session of the process. #[test] fn the_thread_setting_is_reserved() { let spec = crate::cmd::all().into_iter().find(|c| c.id == "gp.threads").unwrap(); assert!(spec.effects.contains(crate::policy::Effect::Reserved)); assert!(!spec.mutates, "no undo step"); } /// A long command stops when it is cancelled while it runs (#1562): every command the app runs /// on another thread (its exports and the commands that make a raster) and the long-running tools /// look at the Cancel flag in their work, not only before it starts. Their control is cancelled /// as soon as the work starts ([`rustgis_analysis::control::Control::cancelled_once_started`]), /// so no timing is involved: a run that never looks finishes and is caught by its count of looks. #[cfg(not(target_arch = "wasm32"))] #[test] fn long_commands_stop_when_cancelled_while_they_run() { use rustgis_analysis::control::Control; // Removed however the test ends. struct Dir(std::path::PathBuf); impl Drop for Dir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } let dir = Dir(std::env::temp_dir().join(format!("rustgis-cancel-{}", std::process::id()))); let _ = std::fs::remove_dir_all(&dir.0); std::fs::create_dir_all(&dir.0).unwrap(); let out = |f: &str| dir.0.join(f).display().to_string(); let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "dem.tif", "data": dem_b64()})).unwrap(); let dem = s.map().layers.iter().find(|l| l.is_raster()).unwrap().name.clone(); // What some of them read: a later DEM, a slope, training squares, an imagery workspace, a // two-class scene with its training polygons, and a layout. s.run("raster.function", &json!({"layer": dem, "function": "bandMath", "expression": "b1 + 10", "name": "dem_later"})).unwrap(); s.run("raster.function", &json!({"layer": dem, "function": "slope", "name": "dem_slope"})).unwrap(); s.run("layer.template", &json!({"template": "polygons", "name": "Squares"})).unwrap(); for (name, x) in [("low", 10.002), ("high", 10.018)] { let square = [[x, 45.018], [x + 0.004, 45.018], [x + 0.004, 45.022], [x, 45.022], [x, 45.018]]; let geometry = json!({"type": "Polygon", "coordinates": [square]}); s.run("edit.addFeature", &json!({"layer": "Squares", "geometry": geometry, "attributes": {"name": name}})).unwrap(); } s.run("imagery.workspace", &json!({"kind": "ortho", "name": "Valley", "images": [dem, "dem_later"]})).unwrap(); s.run("imagery.workspaceOptions", &json!({"name": "Valley", "cellSize": 0.002})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); let training = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "field"}, "geometry": {"type": "Polygon", "coordinates": [[[10.025, 45.002], [10.038, 45.002], [10.038, 45.035], [10.025, 45.035], [10.025, 45.002]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "training.geojson", "text": training.to_string()})).unwrap(); s.run("layout.new", &json!({"page": "a4", "orientation": "landscape", "template": "blank"})).unwrap(); s.run("layout.addFrame", &json!({"x": 20, "y": 20, "w": 150, "h": 150})).unwrap(); // What didn't stop, and how often it looked. let mut kept_going = Vec::new(); let mut note = |what: &str, result: Result<(), String>, control: &Control| { let stopped = result.as_ref().is_err_and(|e| e.contains("cancelled")); if !stopped || control.looked() == 0 { kept_going.push(format!("{what}: {result:?}, looked {} times", control.looked())); } }; let rasters = [ ("analysis.viewshed", json!({"layer": dem, "lon": 10.0055, "lat": 45.0395, "height": 2})), ("analysis.interpolate", json!({"layer": "Cities", "field": "pop_max"})), ( "analysis.suitability", json!({"criteria": [ {"layer": dem, "ranges": [[0, 250, 1], [250, 1000, 10]], "weight": 2}, {"layer": "dem_slope", "ranges": [[0, 5, 10], [5, 90, 1]], "weight": 1}]}), ), ("raster.function", json!({"layer": dem, "function": "hillshade"})), ("raster.functionChain", json!({"layer": dem, "steps": [{"function": "slope"}, {"function": "threshold", "value": 10}]})), ("raster.classify", json!({"layer": dem, "classes": 3})), ("raster.classifySupervised", json!({"layer": dem, "training": "Squares", "classField": "name"})), ("raster.change", json!({"before": dem, "after": "dem_later", "method": "difference"})), ("raster.index", json!({"layer": dem, "index": "ndvi", "nir": 1, "red": 1})), ("raster.process", json!({"layer": dem, "operation": "resample", "factor": 4})), ("imagery.mosaic", json!({"name": "Valley"})), ( "dl.classify", json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 30, "model": "landcover"}), ), ]; // Every command that runs in the background is here, and only those. fn listed<'a>(l: &[(&'a str, Value)]) -> std::collections::BTreeSet<&'a str> { l.iter().map(|(c, _)| *c).collect() } let registered = crate::cmd::workflows::IN_BACKGROUND.iter().map(|(c, ..)| *c).collect::>(); assert_eq!(listed(&rasters), registered, "add a new background raster command here"); for (command, p) in &rasters { let run = crate::cmd::workflows::raster_start(&mut s, command, p).unwrap_or_else(|e| panic!("{command}: {e}")); let id = run.id; let control = Control::cancelled_once_started(); note(command, run.run(&control).map(|_| ()), &control); crate::cmd::workflows::raster_abandon(&mut s, id); } let exports = [ ("share.exportLayer", json!({"layer": "Cities", "path": out("cities.geojson")})), ("share.exportRaster", json!({"layer": dem, "path": out("dem.tif")})), ("share.exportMap", json!({"path": out("map.png"), "width": 320, "height": 200})), ("layout.export", json!({"path": out("layout.pdf"), "dpi": 72})), ("report.export", json!({"path": out("report.pdf"), "dpi": 72})), ]; s.run("report.new", &json!({"layer": "Cities"})).unwrap(); let registered = crate::cmd::exports::IN_BACKGROUND.iter().map(|(c, ..)| *c).collect::>(); assert_eq!(listed(&exports), registered, "add a new background export here"); for (command, p) in &exports { let run = crate::cmd::exports::export_start(&mut s, command, p).unwrap_or_else(|e| panic!("{command}: {e}")); let control = Control::cancelled_once_started(); note(command, run.run(&control).map(|_| ()), &control); } // The tools whose work grows past a second or two on real data: buffers, overlays, joins, // hulls, densities and interpolation. Run in the background as the Analysis pane runs them; a // new long tool is added here (AGENTS.md › Never hold the window). let tools = [ ("buffer", json!({"input": "Cities", "distance": 100})), ("dissolve", json!({"input": "Countries"})), ("spatialJoin", json!({"target": "Cities", "join": "Countries"})), ("clip", json!({"input": "Rivers", "clip": "Countries"})), ("erase", json!({"input": "Rivers", "erase": "Countries"})), ("intersect", json!({"input": "Rivers", "overlay": "Countries"})), ("identity", json!({"input": "Rivers", "overlay": "Countries"})), ("union", json!({"input": "Lakes", "overlay": "Countries"})), ("symmetricalDifference", json!({"input": "Lakes", "overlay": "Countries"})), ("update", json!({"input": "Countries", "update": "Lakes"})), ("split", json!({"input": "Rivers", "split": "Countries"})), ("simplify", json!({"input": "Countries", "tolerance": 1})), ("countPoints", json!({"polygons": "Countries", "points": "Cities"})), ("convexHull", json!({"input": "Countries", "group": "None", "output": "Hulls_each"})), ("convexHull", json!({"input": "Countries", "group": "All", "output": "Hulls_all"})), ("kernelDensity", json!({"input": "Cities", "crs": "EPSG:3857"})), ("kriging", json!({"input": "Cities", "field": "pop_max"})), ]; for (tool, params) in &tools { // The parameters are good: a run that isn't cancelled succeeds. s.run("gp.run", &json!({"tool": tool, "params": params})).unwrap_or_else(|e| panic!("{tool}: {e}")); let run = crate::cmd::gp::tool_start(&mut s, tool, params).unwrap_or_else(|e| panic!("{tool}: {e}")); let id = run.id; let control = Control::cancelled_once_started(); let done = run.run(&control); // The result is kept, then `gp.finish` applies it (or says why it can't), as in the app. let finished = crate::cmd::gp::tool_complete(&mut s, id, done).and_then(|()| s.run("gp.finish", &json!({"id": id})).map(|_| ())); // With its parameters: a tool listed twice (convexHull) is told apart. note(&format!("{tool} {params}"), finished, &control); } assert!(kept_going.is_empty(), "these ran on after Cancel:\n{}", kept_going.join("\n")); } /// A GeoLibre step of a model is worked out on the copy and taken by the run here, as a built-in /// tool's is (#1562): its program works in a scratch folder of its own, so running it ahead writes /// nothing twice. Skipped without a GeoLibre runner (set RUSTGIS_GEOLIBRE). #[cfg(all(feature = "geolibre", not(target_arch = "wasm32")))] #[test] fn a_models_geolibre_step_runs_ahead_and_is_taken() { use crate::cmd::reads::{read_ahead, read_land}; use rustgis_analysis::control::Control; let mut s = with_raster(); if s.run("gp.geolibre", &Value::Null).unwrap()["available"] != true { eprintln!("skipped: no GeoLibre runner (set RUSTGIS_GEOLIBRE)"); return; } s.run("model.new", &json!({"name": "Relief", "steps": [{"tool": "geolibre:slope", "params": {"input": "scene"}}]})).unwrap(); let read = read_ahead(&mut s, "model.run", &json!({"name": "Relief"})).unwrap().run(&Control::stoppable()).unwrap(); read_land(&mut s, read).unwrap(); assert_eq!(s.tools_taken, 1, "the GeoLibre step was taken, not run again here"); assert!(s.map().layer_by_name("Slope").is_some()); } /// A script's zone of spectral means is read on the script's copy of the session, off the UI /// thread (#1562), and the run here takes it: the same answer as read here. A zone changed /// meanwhile is read again here, and a cancelled run looks at Cancel as it reads the zone. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_scripts_spectral_zone_is_read_on_its_copy() { use crate::cmd::reads::{read_ahead, read_land}; use base64::Engine; use rustgis_analysis::control::Control; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let enc = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b); let gt = GeoTransform { origin_x: 10.0, origin_y: 45.0, pixel_width: 0.01, pixel_height: -0.01 }; let bands = (0..3).map(|b| (0..16).map(|i| (30 - b * 10 + i) as f32).collect()).collect(); let r = Raster::new("scene", 4, 4, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt8").unwrap(); let tiff = enc(&geotiff::write(&r, 0).unwrap()); let zone = |x1: f64| { enc(json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[10.0, 45.0], [x1, 45.0], [x1, 44.98], [10.0, 44.98], [10.0, 45.0]]]}}]}) .to_string() .as_bytes()) }; let session = || { let mut s = Session::new(Project::default()); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": tiff})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "zone.geojson", "data": zone(10.02)})).unwrap(); s }; let p = json!({"layer": "scene", "zone": "zone"}); let here = session().run("raster.spectral", &p).unwrap(); assert_eq!(here["pixels"], 4, "{here}"); let script = json!({"script": format!("raster.spectral {p}")}); let mut s = session(); let ahead = read_ahead(&mut s, "script.run", &script).unwrap(); assert!(ahead.reads(), "the script runs on a copy"); let read = ahead.run(&Control::stoppable()).unwrap(); let v = read_land(&mut s, read).unwrap(); assert_eq!(s.zones_taken, 1); assert!(s.zones_ahead.is_none()); assert_eq!(v["ran"][0]["result"], here, "{v}"); // The zone changed while the copy ran: read again here, on the zone as it is now. let mut s = session(); let read = read_ahead(&mut s, "script.run", &script).unwrap().run(&Control::stoppable()).unwrap(); s.run("layer.remove", &json!({"layer": "zone"})).unwrap(); s.run("layer.addDataBytes", &json!({"fileName": "zone.geojson", "data": zone(10.04)})).unwrap(); let now = read_land(&mut s, read).unwrap(); assert_eq!(s.zones_taken, 0); let direct = s.run("raster.spectral", &p).unwrap(); assert_eq!(direct["pixels"], 8, "{direct}"); assert_eq!(now["ran"][0]["result"], direct, "the zone as it is now"); // Cancelled as the copy starts: the zone's read looks at it, and nothing lands. let mut s = session(); let control = Control::cancelled_once_started(); assert!(read_ahead(&mut s, "script.run", &script).unwrap().run(&control).is_err()); assert!(control.looked() > 0, "the zone's read looked at Cancel"); } /// The Error Inspector's topology check runs ahead on a thread of its own in the desktop app /// (#1291): the command takes it as it lands, with the same answer and errors as checked here; a /// layer changed meanwhile is checked again; a cancelled check looks at Cancel and lands nothing. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_topology_check_runs_ahead_and_is_taken() { use crate::cmd::reads::{read_ahead, read_land}; use rustgis_analysis::control::Control; let square = |x: f64| { json!({"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[x, 0.0], [x + 1.0, 0.0], [x + 1.0, 1.0], [x, 1.0], [x, 0.0]]]}}) }; let parcels = |xs: &[f64]| json!({"type": "FeatureCollection", "features": xs.iter().map(|x| square(*x)).collect::>()}).to_string(); let session = || { let mut s = Session::new(Project::default()); s.run("layer.addDataText", &json!({"fileName": "parcels.geojson", "text": parcels(&[0.0, 0.5, 3.0])})).unwrap(); s.run("topology.rules", &json!({"add": {"rule": "mustNotOverlap", "layer": "parcels"}})).unwrap(); s }; let mut here = session(); let expected = here.run("topology.validate", &Value::Null).unwrap(); assert_eq!(expected["errors"], 1, "{expected}"); let mut s = session(); let ahead = read_ahead(&mut s, "topology.validate", &json!({})).unwrap(); assert!(ahead.reads()); let read = ahead.run(&Control::stoppable()).unwrap(); assert!(s.topology_errors.is_empty(), "nothing is kept before it lands"); assert_eq!(read_land(&mut s, read).unwrap(), expected); assert!(s.read_ahead.is_none(), "the check was taken"); assert_eq!(s.topology_errors.len(), here.topology_errors.len()); // The layer changed while it was checked: said, not checked again here (which would hold the // window), and the errors it had are kept. let mut s = session(); s.topology_errors = here.topology_errors.clone(); let read = read_ahead(&mut s, "topology.validate", &json!({})).unwrap().run(&Control::stoppable()).unwrap(); s.run("layer.remove", &json!({"layer": "parcels"})).unwrap(); s.run("layer.addDataText", &json!({"fileName": "parcels.geojson", "text": parcels(&[0.0, 2.0, 3.0])})).unwrap(); let e = read_land(&mut s, read).unwrap_err(); assert!(e.contains("changed while they were checked"), "{e}"); assert!(s.read_ahead.is_none()); assert_eq!(s.topology_errors.len(), 1, "the errors it had are kept"); // Checked where it is asked (a script, the assistant), it checks the layer as it is now. assert_eq!(s.run("topology.validate", &Value::Null).unwrap()["errors"], 0); // A rule whose covering layer is gone still has its layer's latitudes checked. let mut s = session(); s.run("layer.addDataText", &json!({"fileName": "cover.geojson", "text": parcels(&[0.0])})).unwrap(); let points = json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [0.5, 0.5]}}]}); s.run("layer.addDataText", &json!({"fileName": "wells.geojson", "text": points.to_string()})).unwrap(); s.run("topology.rules", &json!({"add": {"rule": "mustBeCoveredBy", "layer": "wells", "by": "cover"}})).unwrap(); s.run("layer.remove", &json!({"layer": "cover"})).unwrap(); let mark = s.notice_mark(); assert_eq!(s.run("topology.validate", &Value::Null).unwrap()["errors"], 1, "the overlap rule's error, the other rule's none"); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("1 topology rule(s) weren't checked") && t.contains("'wells'")), "{said:?}"); // Cancelled as it starts: the check looks at it, and nothing lands. let mut s = session(); let control = Control::cancelled_once_started(); assert!(read_ahead(&mut s, "topology.validate", &json!({})).unwrap().run(&control).is_err()); assert!(control.looked() > 0, "the check looked at Cancel"); // No rules: nothing to read ahead. let mut s = Session::new(Project::default()); assert!(!read_ahead(&mut s, "topology.validate", &json!({})).unwrap().reads()); } /// A model trained within the work limit of a run that can't be cancelled says what it left out, /// in a notice naming it and as `"limited"` in the answer, from `dl.train` and from `dl.classify` /// when it trains; one trained whole says nothing (AGENTS.md › Never silent). #[test] fn a_model_trained_within_the_work_limit_says_so() { let mut s = world(); s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": two_class_b64()})).unwrap(); let training = json!({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"class": "water"}, "geometry": {"type": "Polygon", "coordinates": [[[10.002, 45.002], [10.015, 45.002], [10.015, 45.035], [10.002, 45.035], [10.002, 45.002]]]}}, {"type": "Feature", "properties": {"class": "field"}, "geometry": {"type": "Polygon", "coordinates": [[[10.025, 45.002], [10.038, 45.002], [10.038, 45.035], [10.025, 45.035], [10.025, 45.002]]]}}]}); s.run("layer.addDataText", &json!({"fileName": "training.geojson", "text": training.to_string()})).unwrap(); let limited = |s: &Session, mark: u64, name: &str| { s.notices_since(mark) .iter() .filter(|n| n.text.starts_with(&format!("{name}: trained on ")) && n.severity == rustgis_data::diagnostics::Severity::Warning) .count() }; let small = json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 5, "model": "small"}); let mark = s.notice_mark(); let r = s.run("dl.train", &small).unwrap(); assert!(r.get("limited").is_none(), "{r}"); assert_eq!(limited(&s, mark, "small"), 0); // The largest network for every epoch passes the work limit: fewer epochs, said. let big = json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 500, "hidden": [256, 256, 256, 256], "model": "big"}); let mark = s.notice_mark(); let r = s.run("dl.train", &big).unwrap(); let said = r["limited"].as_str().unwrap_or_else(|| panic!("{r}")); assert!(said.contains("of 500 epochs"), "{said}"); assert_eq!(limited(&s, mark, "big"), 1); // dl.classify that trains says it as the model is kept. let mark = s.notice_mark(); let r = s .run( "dl.classify", &json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": 500, "hidden": [256, 256, 256, 256], "model": "big2"}), ) .unwrap(); assert!(r["limited"].as_str().is_some(), "{r}"); assert_eq!(limited(&s, mark, "big2"), 1); // More epochs than a training runs are refused, not cut without a word. for epochs in [json!(0), json!(501), json!(5000), json!(2.5), json!("2.5"), json!("500 epochs")] { let e = s.run("dl.train", &json!({"layer": "scene", "training": "training", "classField": "class", "epochs": epochs})).unwrap_err(); assert!(e.contains("epochs is 1 to 500"), "{epochs}: {e}"); } // A count as a numeric string is read as other counts are. let r = s .run("dl.train", &json!({"layer": "scene", "training": "training", "classField": "class", "bands": [1, 2], "epochs": "3"})) .unwrap(); assert_eq!(r["epochs"], 3, "{r}"); }