//! Runtime tests: the scripting API, prefabs, the document driver, errors. use crate::doc::{Doc, Object}; use crate::{Bundle, Runtime}; use isokit_core::{render, RenderOpts}; use serde_json::{json, Map, Value}; fn rt(main: &str) -> Runtime { let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; b.sources.insert("scene.rhai".into(), main.into()); Runtime::new(&b).unwrap_or_else(|e| panic!("{e}")) } fn cfg() -> Map { Map::new() } fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object { Object { id: id.into(), kind: kind.into(), x, y, props: props.as_object().cloned().unwrap_or_default(), ..Default::default() } } #[test] fn script_draws_with_mixed_numbers() { let r = rt(r#" let s = sprite("hall"); s.box(0, 0, 10, 6.5, 0, 3, "wall", #{ top: "roof" }); info("hall", "Hall", "10 x 6.5 m"); tag(5, 3, 4, "Hall", 5); "#); let b = r.build("roof", 0, &cfg(), &Doc::default()).unwrap(); let out = render(&b.scene, 0, &RenderOpts::default()); assert!(out.html.contains("iso-roof iso-t"), "{}", out.html); assert!(out.html.contains("iso-wall iso-l")); assert!(out.html.contains(">Hall<")); assert_eq!(out.info["hall"].detail, "10 x 6.5 m"); } #[test] fn methods_from_the_library_work_on_sprites() { let r = rt(r#" let s = sprite("t"); s.tree_at(0, 0, 1.5); sprite("c").car(0, 0, 4.4, 1.8, "car2"); "#); let b = r.build("site", 1, &cfg(), &Doc::default()).unwrap(); let out = render(&b.scene, 1, &RenderOpts::default()); assert!(out.html.contains("iso-leaf iso-l")); assert!(out.html.contains("iso-glassd")); } #[test] fn seq_group_and_closures() { let r = rt(r#" let s = sprite("x"); let h = 2.0; s.seq([[0, 0, |q| q.box(0, 0, 1, 1, 0, h, "wall")], [5, 0, |q| q.box(5, 0, 6, 1, 0, h, "roof")]]); s.group("a drive", |q| q.box(0, 3, 1, 4, 0, 1, "car1")); "#); let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap(); let out = render(&b.scene, 0, &RenderOpts::default()); // from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0 let (a, c) = ( out.html.find("iso-wall").unwrap(), out.html.find("iso-roof").unwrap(), ); assert!(a < c); assert!(out.html.contains("")); } #[test] fn errors_point_at_the_line() { let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; b.sources.insert( "scene.rhai".into(), "let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into(), ); let r = Runtime::new(&b).unwrap(); let e = r .build("site", 0, &cfg(), &Doc::default()) .err() .expect("error"); assert_eq!(e.file, "scene.rhai"); assert_eq!(e.line, Some(3), "{e}"); assert!(e.message.contains("number"), "{e}"); b.sources.insert("scene.rhai".into(), "let x = ;".into()); let e = Runtime::new(&b).err().expect("parse error"); assert_eq!(e.line, Some(1), "{e}"); } #[test] fn imports_resolve_from_the_bundle() { let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; b.sources.insert( "lib/kiosk.rhai".into(), "fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into(), ); b.sources.insert( "scene.rhai".into(), "import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into(), ); let r = Runtime::new(&b).unwrap(); let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene; assert_eq!(s.sprite_count(), 1); } #[test] fn catalog_lists_library_and_project_prefabs() { let mut b = Bundle { main: None, prelude: vec!["lib/kiosk.rhai".into()], ..Default::default() }; b.sources.insert( "lib/kiosk.rhai".into(), r#" fn meta_kiosk() { #{ label: "Kiosk", category: "Shop", props: [ #{ key: "h", type: "float", def: 2.5 } ] } } fn prefab_kiosk(o) { sprite().box(-1, -1, 1, 1, 0, o.p.h, "wall"); } "# .into(), ); let r = Runtime::new(&b).unwrap(); let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect(); for k in [ "site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk", ] { assert!(kinds.contains(&k), "{k} missing from {kinds:?}"); } let k = r.prefab("kiosk").unwrap(); assert_eq!(k.label, "Kiosk"); assert_eq!(k.source, "lib/kiosk.rhai"); assert_eq!(k.props[0].default, json!(2.5)); } #[test] fn document_objects_are_placed_in_their_frame() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = Doc::default(); let mut b = obj( "hall", "building", 30.0, 20.0, json!({"w": 10, "d": 6, "floors": 1}), ); b.rot = 90.0; b.name = "Main hall".into(); b.tag = "Hall".into(); doc.objects.push(b); doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({}))); let built = r.build("roof", 1, &cfg(), &doc).unwrap(); // turned a quarter: 6 m along x, 10 m along y, centred on (30, 20) let bx = *built .boxes .get("hall") .unwrap_or_else(|| panic!("{:?}", built.scene.warnings)); assert!( (bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9, "{bx:?}" ); assert!( (bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9, "{bx:?}" ); let out = render(&built.scene, 1, &RenderOpts::default()); assert!(out.html.contains("data-obj=\"hall\"")); assert!(out.html.contains("data-obj=\"t1\"")); assert_eq!(out.info["hall"].name, "Main hall"); assert_eq!(out.info["t1"].name, "Tree"); assert!(out.html.contains(">Hall<")); assert!( built.scene.warnings.is_empty(), "{:?}", built.scene.warnings ); } #[test] fn levels_cut_buildings_open_and_filter_objects() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = Doc::default(); doc.objects .push(obj("b", "building", 0.0, 0.0, json!({"floors": 2}))); let mut desk = obj("desk", "tree", 0.0, 0.0, json!({})); desk.levels = vec!["gf".into()]; doc.objects.push(desk); let closed = r.build("roof", 0, &cfg(), &doc).unwrap(); let gf = r.build("gf", 0, &cfg(), &doc).unwrap(); let up = r.build("3f", 0, &cfg(), &doc).unwrap(); assert!(!closed.boxes.contains_key("desk")); assert!(gf.boxes.contains_key("desk")); // cut open: a floor sprite plus four wall sprites assert!(gf.scene.sprite_count() > closed.scene.sprite_count()); let html = render(&gf.scene, 0, &RenderOpts::default()).html; assert!(html.contains("iso-z-floor iso-t")); let html = render(&up.scene, 0, &RenderOpts::default()).html; assert!( html.contains("class=\"iso-dim\""), "ghosted above the top storey" ); } #[test] fn every_library_prefab_renders_cleanly_in_every_rotation() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = Doc::default(); for (i, m) in r.catalog().iter().enumerate() { let mut o = obj(&format!("o{i}"), &m.kind, i as f64 * 15.0, 0.0, json!({})); // switch every bool prop on to exercise all branches for p in &m.props { if p.kind == "bool" { o.props.insert(p.key.clone(), json!(true)); } } doc.objects.push(o); } for level in ["site", "gf", "1f"] { for rot in 0..4 { let b = r .build(level, rot, &cfg(), &doc) .unwrap_or_else(|e| panic!("{level}/{rot}: {e}")); assert!( b.scene.warnings.is_empty(), "{level}/{rot}: {:?}", b.scene.warnings ); let out = render(&b.scene, rot, &RenderOpts::default()); assert!(!out.html.contains("NaN"), "{level}/{rot}"); for m in r.catalog() { let id = format!( "o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap() ); assert!( b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind ); } } } } #[test] fn unknown_types_and_failing_prefabs_warn_but_render() { let mut b = Bundle { main: None, prelude: vec!["lib/bad.rhai".into()], ..Default::default() }; b.sources.insert( "lib/bad.rhai".into(), "fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into(), ); let r = Runtime::new(&b).unwrap(); let mut doc = Doc::default(); doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({}))); doc.objects.push(obj("x", "bad", 0.0, 0.0, json!({}))); doc.objects.push(obj("t", "tree", 5.0, 0.0, json!({}))); let built = r.build("site", 0, &cfg(), &doc).unwrap(); let w = built.scene.warnings.join("\n"); assert!(w.contains("object u: unknown type"), "{w}"); assert!(w.contains("object x:"), "{w}"); assert!(built.boxes.contains_key("t")); } #[test] fn cfg_reaches_the_script() { let r = rt( r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#, ); let mut c = Map::new(); c.insert("big".into(), json!(true)); let b = r.build("site", 0, &c, &Doc::default()).unwrap(); assert_eq!(b.scene.layers[2].sprites[0].world_box().unwrap()[5], 9.0); } /// A 4 x 3 m room: floor, four walls on its edges, a window and a door. fn room_doc() -> Doc { let mut doc = Doc::default(); doc.objects .push(obj("floor", "floor", 0.0, 0.0, json!({"w": 4, "d": 3}))); for (id, x, y, rot, w) in [ ("south", 0.0, -1.5, 0.0, 4.0), ("north", 0.0, 1.5, 0.0, 4.0), ("west", -2.0, 0.0, 90.0, 3.0), ("east", 2.0, 0.0, 90.0, 3.0), ] { let mut o = obj(id, "house_wall", x, y, json!({ "w": w })); o.rot = rot; doc.objects.push(o); } // a little off the wall line: still on the south wall doc.objects .push(obj("win", "window", 0.5, -1.4, json!({"w": 1.2}))); let mut door = obj("door", "door", 2.05, 0.4, json!({"open": true})); door.rot = 90.0; doc.objects.push(door); doc } fn sprites_of(b: &crate::Built, id: &str) -> usize { b.scene .layers .iter() .flat_map(|l| l.sprites.iter()) .filter(|s| s.obj.as_deref() == Some(id)) .count() } #[test] fn windows_and_doors_sit_on_walls() { let r = Runtime::new(&Bundle::default()).unwrap(); let doc = room_doc(); let b = r.build("site", 0, &cfg(), &doc).unwrap(); assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); // the window snaps onto the south wall's line and the wall leaves a gap let w = b.boxes["win"]; assert!( (w[1] + 1.575).abs() < 1e-6 && (w[4] + 1.425).abs() < 1e-6, "{w:?}" ); assert!( (w[0] + 0.1).abs() < 1e-6 && (w[3] - 1.1).abs() < 1e-6, "{w:?}" ); assert_eq!(sprites_of(&b, "south"), 2, "wall split around the window"); assert_eq!(sprites_of(&b, "north"), 1); assert_eq!(sprites_of(&b, "east"), 2, "wall split around the door"); // the open door swings into the room (rot 90 on a rot 90 wall: towards -x) let d = b.boxes["door"]; assert!(d[0] < 1.5 && d[3] <= 2.08, "{d:?}"); let html = render(&b.scene, 0, &RenderOpts::default()).html; assert!(html.contains("iso-glassp")); // corners: the walls along x run through, the others stop at their faces let (sw, ww) = (b.boxes["south"], b.boxes["west"]); assert!( (sw[0] + 2.075).abs() < 1e-6 && (sw[3] - 2.075).abs() < 1e-6, "{sw:?}" ); assert!( (ww[1] + 1.425).abs() < 1e-6 && (ww[4] - 1.425).abs() < 1e-6, "{ww:?}" ); for rot in 0..4u8 { for lv in ["site", "gf"] { let b = r.build(lv, rot, &cfg(), &doc).unwrap(); let out = render(&b.scene, rot, &RenderOpts::default()); assert_eq!(out.cycles, 0, "{lv}/{rot}: depth-sort cycles"); } } // moved off every wall, the window stands alone as a wall piece let mut alone = doc.clone(); alone.get_mut("win").unwrap().y = -5.0; let b = r.build("site", 0, &cfg(), &alone).unwrap(); assert_eq!(sprites_of(&b, "south"), 1); assert!((b.boxes["win"][5] - 2.8).abs() < 1e-6); } #[test] fn walls_meeting_at_a_t_or_in_line_do_not_overlap() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = room_doc(); let mut inner = obj("inner", "house_wall", 0.0, 0.0, json!({"w": 3.0, "t": 0.1})); inner.rot = 90.0; doc.objects.push(inner); // the north wall in two pieces meeting at x = 1 doc.get_mut("north").unwrap().props = json!({"w": 3.0}).as_object().cloned().unwrap(); doc.get_mut("north").unwrap().x = -0.5; doc.objects .push(obj("north2", "house_wall", 1.5, 1.5, json!({"w": 1.0}))); let b = r.build("site", 1, &cfg(), &doc).unwrap(); let i = b.boxes["inner"]; assert!( (i[1] + 1.425).abs() < 1e-6 && (i[4] - 1.425).abs() < 1e-6, "{i:?}" ); let (n1, n2) = (b.boxes["north"], b.boxes["north2"]); assert!( (n1[3] - 1.0).abs() < 1e-6 && (n2[0] - 1.0).abs() < 1e-6, "{n1:?} {n2:?}" ); assert!((n1[0] + 2.075).abs() < 1e-6 && (n2[3] - 2.075).abs() < 1e-6); } /// A 10 x 6 m fence perimeter, a fence across it (a T at both ends; at the /// north it meets the joint of the north fence's two pieces) and a spur from /// the east fence to a site wall. fn fence_doc(style: &str) -> Doc { let mut doc = Doc::default(); for (id, x, y, rot, w) in [ ("south", 0.0, -3.0, 0.0, 10.0), ("north", -2.5, 3.0, 0.0, 5.0), ("north2", 2.5, 3.0, 0.0, 5.0), ("west", -5.0, 0.0, 90.0, 6.0), ("east", 5.0, 0.0, 90.0, 6.0), ("inner", 0.0, 0.0, 90.0, 6.0), ("spur", 6.5, 0.0, 0.0, 3.0), ] { let mut o = obj(id, "fence", x, y, json!({ "w": w, "style": style })); o.rot = rot; doc.objects.push(o); } let mut wall = obj("wall", "wall", 8.0, 0.0, json!({"w": 10.0})); wall.rot = 90.0; doc.objects.push(wall); doc } #[test] fn fences_join_like_walls_in_every_design() { let r = Runtime::new(&Bundle::default()).unwrap(); for (style, ht) in [ ("mesh", 0.04), ("wood", 0.05), ("bars", 0.04), ("half stone", 0.16), ("cobble", 0.22), ("brick", 0.18), ] { let doc = fence_doc(style); let b = r.build("site", 0, &cfg(), &doc).unwrap(); assert!( b.scene.warnings.is_empty(), "{style}: {:?}", b.scene.warnings ); let bx = |id: &str| b.boxes[id]; let near = |a: f64, e: f64| (a - e).abs() < 1e-6; // corners: the fence along x runs through, the other stops at its face let (s, w) = (bx("south"), bx("west")); assert!( near(s[0], -5.0 - ht) && near(s[3], 5.0 + ht), "{style} south {s:?}" ); assert!( near(w[1], -3.0 + ht) && near(w[4], 3.0 - ht), "{style} west {w:?}" ); // a T at the south, the end of a straight run at the north: it stops at the faces let i = bx("inner"); assert!( near(i[1], -3.0 + ht) && near(i[4], 3.0 - ht), "{style} inner {i:?}" ); // in line: the lower piece runs through the shared post, the other starts at it let (n1, n2) = (bx("north"), bx("north2")); assert!( near(n1[3], ht) && near(n2[0], ht), "{style} north {n1:?} {n2:?}" ); // the spur stops at the east fence and at the site wall's face let sp = bx("spur"); assert!( near(sp[0], 5.0 + ht) && near(sp[3], 8.0 - 0.12), "{style} spur {sp:?}" ); for rot in 0..4u8 { let b = r.build("site", rot, &cfg(), &doc).unwrap(); let out = render(&b.scene, rot, &RenderOpts::default()); assert_eq!(out.cycles, 0, "{style}/{rot}: depth-sort cycles"); } } // the brick wall has pillars as their own sprites, with brick courses on its faces let b = r.build("site", 0, &cfg(), &fence_doc("brick")).unwrap(); assert!(sprites_of(&b, "south") > 4); assert!(render(&b.scene, 0, &RenderOpts::default()) .html .contains("iso-seam")); } #[test] fn site_walls_join_at_corners() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = Doc::default(); for (id, x, y, rot, w) in [ ("s", 0.0, -3.0, 0.0, 10.0), ("n", 0.0, 3.0, 0.0, 10.0), ("w", -5.0, 0.0, 90.0, 6.0), ("e", 5.0, 0.0, 90.0, 6.0), ] { let mut o = obj(id, "wall", x, y, json!({ "w": w })); o.rot = rot; doc.objects.push(o); } let b = r.build("site", 0, &cfg(), &doc).unwrap(); let (s, w) = (b.boxes["s"], b.boxes["w"]); assert!( (s[0] + 5.12).abs() < 1e-6 && (s[3] - 5.12).abs() < 1e-6, "{s:?}" ); assert!( (w[1] + 2.88).abs() < 1e-6 && (w[4] - 2.88).abs() < 1e-6, "{w:?}" ); for rot in 0..4u8 { let b = r.build("site", rot, &cfg(), &doc).unwrap(); assert_eq!(render(&b.scene, rot, &RenderOpts::default()).cycles, 0); } } #[test] fn cut_levels_lower_the_walls_in_front_of_rooms() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = room_doc(); doc.objects .push(obj("roof", "roof", 0.0, 0.0, json!({"w": 4.15, "d": 3.15}))); let mut up = obj("chair", "chair", 0.0, 0.0, json!({})); up.z = 3.0; doc.objects.push(up); for rot in 0..4u8 { let site = r.build("site", rot, &cfg(), &doc).unwrap(); let gf = r.build("gf", rot, &cfg(), &doc).unwrap(); let tops = |b: &crate::Built| -> Vec { ["south", "north", "west", "east"] .iter() .map(|id| b.boxes[*id][5]) .collect() }; assert!(tops(&site).iter().all(|z| (z - 2.8).abs() < 1e-6)); // two walls face the viewer: cut low; the two behind the room stay full let t = tops(&gf); let low = t.iter().filter(|z| (*z - 1.0).abs() < 1e-6).count(); let full = t.iter().filter(|z| (*z - 2.8).abs() < 1e-6).count(); assert_eq!((low, full), (2, 2), "rotation {rot}: {t:?}"); // the roof is a full roof on the site level, an outline when cut open assert!(site.boxes["roof"][5] > 4.0); assert!( (gf.boxes["roof"][5] - 0.02).abs() < 1e-6, "outline on the floor" ); // the chair on the first floor: left out on GF, there on 1F assert!(!gf.boxes.contains_key("chair")); let f1 = r.build("1f", rot, &cfg(), &doc).unwrap(); assert!(f1.boxes.contains_key("chair")); assert!(f1.boxes["south"][5] > 2.7, "GF closed below the cut storey"); assert!(gf.scene.warnings.is_empty(), "{:?}", gf.scene.warnings); } } #[test] fn object_queries_and_frames() { let r = rt(r#" let s = objects_of("floor"); let a = objects_at(["floor"], 1.9, 1.4); let b = objects_at(["floor"], 2.1, 0.0); let w = objects_of("house_wall")[3]; frame(w); let p = to_world(1.0, 0.5); let q = to_local(p[0], p[1]); let m = mount_of(objects_of("door")[0], ["house_wall"]); let on = mounted(w, ["window", "door"], ["house_wall"]); frame(0, 0, 0, 0); sprite("probe").box(0, 0, s.len(), a.len() + b.len() * 10, 0, 1, "wall"); sprite("frame").box(p[0], p[1], p[0] + q[0], p[1] + q[1], 0, 1, "wall"); sprite("mount").box(0, 0, m.u + 10.0, on.len(), 0, m.v + 10.0, "wall"); "#); let b = r.build("site", 0, &cfg(), &room_doc()).unwrap(); let probe = b.scene.layers[2] .sprites .iter() .find(|s| s.id == "probe") .unwrap() .world_box() .unwrap(); assert_eq!( (probe[3], probe[4]), (1.0, 1.0), "one floor, holding one point" ); let f = b.scene.layers[2] .sprites .iter() .find(|s| s.id == "frame") .unwrap() .world_box() .unwrap(); // east wall at (2, 0) turned 90: local (1, 0.5) is world (1.5, 1) assert_eq!([f[0], f[1], f[3], f[4]], [1.5, 1.0, 2.5, 1.5]); let m = b.scene.layers[2] .sprites .iter() .find(|s| s.id == "mount") .unwrap() .world_box() .unwrap(); // the door at (2.05, 0.4) is 0.4 along the east wall, 0.05 off it assert!( (m[3] - 10.4).abs() < 1e-9 && (m[5] - 9.95).abs() < 1e-9, "{m:?}" ); assert_eq!(m[4], 1.0, "one opening on the east wall"); }