//! 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 ponds_have_fish_swimming_in_every_shape() { let r = Runtime::new(&Bundle::default()).unwrap(); for (shape, edge, rot) in [ ("natural", "stone", 0.0), ("round", "grass", 90.0), ("square", "stone", 0.0), ] { let mut doc = Doc::default(); let mut o = obj( "pond", "pond", 3.0, -2.0, json!({"w": 9, "d": 5, "shape": shape, "edge": edge, "fish": 6, "lilies": 4}), ); o.rot = rot; doc.objects.push(o); for view in 0..4u8 { let b = r.build("site", view, &cfg(), &doc).unwrap(); assert!( b.scene.warnings.is_empty(), "{shape}: {:?}", b.scene.warnings ); let out = render(&b.scene, view, &RenderOpts::default()); assert_eq!( out.html.matches("iso-a iso-swim").count(), 6, "{shape}/{view}" ); assert!(!out.html.contains("NaN"), "{shape}/{view}"); assert!(out.html.contains("iso-fish iso-koi") && out.html.contains("iso-lily")); assert!(out.html.contains("iso-a iso-rip")); assert_eq!(out.cycles, 0); } // the pond stays inside its 9 x 5 m (turned: 5 x 9 m) rectangle; the reeds stand on its edge let bx = r.build("site", 0, &cfg(), &doc).unwrap().boxes["pond"]; let (hw, hd) = if rot == 0.0 { (4.5, 2.5) } else { (2.5, 4.5) }; assert!( bx[0] >= 3.0 - hw - 1e-6 && bx[3] <= 3.0 + hw + 1e-6 && bx[1] >= -2.0 - hd - 1e-6 && bx[4] <= -2.0 + hd + 1e-6, "{shape}: {bx:?}" ); } // no fish: no school let mut doc = Doc::default(); doc.objects .push(obj("p", "pond", 0.0, 0.0, json!({"fish": 0}))); let b = r.build("site", 0, &cfg(), &doc).unwrap(); assert!(!render(&b.scene, 0, &RenderOpts::default()) .html .contains("iso-swim")); } fn factory_doc() -> Doc { let mut doc = Doc::default(); for (id, kind, x, y, rot, props) in [ ("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 20})), ("belt1", "conveyor", -1.4, -4.0, 0.0, json!({"w": 6})), ("belt2", "conveyor", 1.6, -1.0, 90.0, json!({"w": 6})), ( "belt3", "conveyor", 1.6, 5.0, 90.0, json!({"w": 6, "speed": 0.8, "rise": 1.6}), ), ( "belt5", "conveyor", 8.0, 6.0, 0.0, json!({"w": 8, "item": "crate", "gap": 2}), ), ( "press3", "producer", -6.0, 8.0, 0.0, json!({"rate": 6, "product": "part", "running": false}), ), ("belt6", "conveyor", -2.0, 8.0, 0.0, json!({"w": 5})), // a closed ring: following the feed back ends after a few belts ("r1", "conveyor", 30.0, -3.0, 0.0, json!({"w": 6})), ("r2", "conveyor", 33.0, 0.0, 90.0, json!({"w": 6})), ("r3", "conveyor", 30.0, 3.0, 180.0, json!({"w": 6})), ("r4", "conveyor", 27.0, 0.0, 270.0, json!({"w": 6})), ] { let mut o = obj(id, kind, x, y, props); o.rot = rot; doc.objects.push(o); } doc } #[test] fn conveyors_carry_what_the_producer_makes() { let r = Runtime::new(&Bundle::default()).unwrap(); assert!(r.prefab("conveyor").unwrap().directed); let doc = factory_doc(); for view in 0..4u8 { let b = r.build("site", view, &cfg(), &doc).unwrap(); assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); let out = render(&b.scene, view, &RenderOpts::default()); assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); assert!(!out.html.contains("NaN")); // goods move on the running belts (not behind the stopped press), the running press's ram goes assert_eq!( out.html.matches("iso-a iso-ride\"").count(), 8, "view {view}" ); assert_eq!(out.html.matches("iso-a iso-ram").count(), 1, "view {view}"); } let b = r.build("site", 1, &cfg(), &doc).unwrap(); let near = |a: f64, e: f64| (a - e).abs() < 1e-6; // the first belt starts flush at the press and stops at the side of the belt it feeds, // which runs through the corner; in line the belts just meet let (b1, b2, b3) = (b.boxes["belt1"], b.boxes["belt2"], b.boxes["belt3"]); assert!(near(b1[0], -4.4) && near(b1[3], 1.2), "belt1 {b1:?}"); assert!(near(b2[1], -4.4) && near(b2[4], 2.0), "belt2 {b2:?}"); assert!(near(b3[1], 2.0), "belt3 {b3:?}"); // goods and their spacing come down the line: 20 a minute at 0.8 m/s is 2.4 m apart let out = render(&b.scene, 1, &RenderOpts::default()); let row = |id: &str, k: &str| { out.info[id] .rows .iter() .find(|r| r.0 == k) .map(|r| r.1.clone()) .unwrap_or_default() }; assert_eq!( row("belt1", "Carries"), "20 cartons a minute, 1.5 m apart (from press1)" ); assert_eq!( row("belt3", "Carries"), "20 cartons a minute, 2.4 m apart (from belt2)" ); assert_eq!(row("belt5", "Carries"), "15 crates a minute, 2 m apart"); assert_eq!( row("belt6", "Carries"), "6 parts a minute, 5 m apart (from press3)" ); assert_eq!( row("press3", "Makes"), "6 parts a minute, one every 10 s (stopped)" ); assert_eq!(row("belt3", "Speed"), "0.8 m/s"); } fn robot_doc(belt1_running: bool) -> Doc { let mut doc = Doc::default(); for (id, kind, x, y, rot, props) in [ ("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 15})), ( "belt1", "conveyor", -1.4, -4.0, 0.0, json!({"w": 6, "running": belt1_running}), ), ("robot1", "robot_arm", 3.2, -4.0, 0.0, json!({})), ("belt2", "conveyor", 3.2, 1.0, 90.0, json!({"w": 6})), ( "robot2", "robot_arm", 3.2, 5.8, 0.0, json!({"colour": "mach2"}), ), ( "robot3", "robot_arm", -6.0, 5.0, 0.0, json!({"running": false, "pick": "+x", "put": "-y", "goods": "drum"}), ), ] { let mut o = obj(id, kind, x, y, props); o.rot = rot; doc.objects.push(o); } doc } #[test] fn robot_arms_pass_goods_from_belt_to_belt() { let r = Runtime::new(&Bundle::default()).unwrap(); let doc = robot_doc(true); for view in 0..4u8 { let b = r.build("site", view, &cfg(), &doc).unwrap(); assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); let out = render(&b.scene, view, &RenderOpts::default()); assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); assert!(!out.html.contains("NaN")); // two running arms with 16 poses each, one shown at a time; the stopped one stands still assert_eq!( out.html.matches("iso-a iso-flip").count(), 32, "view {view}" ); assert_eq!( out.html.matches("iso-flip iso-pose0").count(), 2, "view {view}" ); } let b = r.build("site", 1, &cfg(), &doc).unwrap(); let out = render(&b.scene, 1, &RenderOpts::default()); let row = |id: &str, k: &str| { out.info[id] .rows .iter() .find(|r| r.0 == k) .map(|r| r.1.clone()) .unwrap_or_default() }; assert_eq!(row("robot1", "Moves"), "cartons from belt1 to belt2"); assert_eq!(row("robot1", "Cycle"), "4 s"); // the belt after the arm carries its goods at its pace, starting its loop when they land assert_eq!( row("belt2", "Carries"), "15 cartons a minute, 2 m apart (from robot1)" ); assert!(out .html .contains("animation-duration:4.0s;animation-delay:-1.75s")); assert_eq!(row("robot2", "Moves"), "cartons from belt2 to a pallet"); assert_eq!(row("robot3", "Cycle"), "4 s (stopped)"); // a stopped belt before it: the arm waits let b = r.build("site", 1, &cfg(), &robot_doc(false)).unwrap(); let out = render(&b.scene, 1, &RenderOpts::default()); assert_eq!(out.html.matches("iso-a iso-flip").count(), 0); assert!(out.info["robot1"].rows[1].1.contains("waiting")); } fn street_doc() -> Doc { let mut doc = Doc::default(); let walk = json!({"walk": "both"}); for (id, kind, x, y, rot, props) in [ ( "main", "road", -10.0, 0.0, 0.0, json!({"w": 40, "walk": "both"}), ), ( "side", "road", -14.0, -12.0, 90.0, json!({"w": 24, "walk": "both"}), ), ("bend", "road_curve", 10.0, 0.0, 0.0, walk.clone()), ( "north", "road", 20.0, 18.0, 90.0, json!({"w": 16, "walk": "both"}), ), ( "c1", "road", -30.0, 20.0, 0.0, json!({"w": 20, "walk": "both"}), ), ( "c2", "road", -20.0, 30.0, 90.0, json!({"w": 20, "walk": "both"}), ), ("rb", "roundabout", -54.0, 0.0, 0.0, json!({"arm_n": false})), ] { let mut o = obj(id, kind, x, y, props); o.rot = rot; doc.objects.push(o); } doc } /// Sidewalk strips of object id (paving pieces). fn walks_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)) .flat_map(|s| s.prims.iter()) .filter(|p| matches!(p, isokit_core::Prim::Flat { cls, .. } if cls.contains("g-walk"))) .count() } #[test] fn streets_curve_join_and_meet_at_roundabouts() { let r = Runtime::new(&Bundle::default()).unwrap(); assert_eq!(r.prefab("road").unwrap().corner, "road_curve"); let doc = street_doc(); for view in 0..4u8 { let b = r.build("site", view, &cfg(), &doc).unwrap(); assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); let out = render(&b.scene, view, &RenderOpts::default()); assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); assert!(!out.html.contains("NaN")); } let b = r.build("site", 0, &cfg(), &doc).unwrap(); let near = |a: [f64; 6], want: [f64; 6]| a.iter().zip(want).all(|(a, w)| (a - w).abs() < 1e-3); // the side road ends on the main street's centre line; the main street's south // sidewalk leaves a gap for it (two pieces), its north one runs through assert_eq!(walks_of(&b, "main"), 3); assert_eq!(walks_of(&b, "side"), 2); let sd = b.boxes["side"]; assert!(near(sd, [-19.0, -24.0, 0.0, -9.0, 0.0, 0.12]), "{sd:?}"); // the curve carries the street on round 90 degrees, overlapping both by 4 cm // along its centre line (6 cm at its outer edge); the main street meets the // roundabout's east arm end to end let bd = b.boxes["bend"]; assert!(near(bd, [9.94, -5.0, 0.0, 25.0, 10.06, 0.12]), "{bd:?}"); let mn = b.boxes["main"]; assert!(near(mn, [-30.04, -5.0, 0.0, 10.04, 5.0, 0.12]), "{mn:?}"); // two roads ending at one corner close it: the outer sidewalks go round it let (c1, c2) = (b.boxes["c1"], b.boxes["c2"]); assert!( (c1[3] + 15.0).abs() < 1e-6 && (c1[1] - 15.0).abs() < 1e-6, "{c1:?}" ); assert!( (c2[1] - 15.0).abs() < 1e-6 && (c2[3] + 15.0).abs() < 1e-6, "{c2:?}" ); // the roundabout: arms east, west and south ending 24 m out, the ring's sidewalk to the north let rb = b.boxes["rb"]; assert!( (rb[0] + 78.0).abs() < 1e-6 && (rb[3] + 30.0).abs() < 1e-6 && (rb[1] + 24.0).abs() < 1e-6 && (rb[4] - 16.0).abs() < 0.01, "{rb:?}" ); let html = render(&b.scene, 0, &RenderOpts::default()).html; assert!(html.contains("iso-giveway") && html.contains("iso-kerbf")); } /// A piece of `kind` from plan point a to b: x, y = the middle, rot = its angle, w = its length. fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object { let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let mut p = props; p["w"] = json!(dx.hypot(dy)); let mut o = obj(id, kind, (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, p); o.rot = dy.atan2(dx).to_degrees().rem_euclid(360.0); o } #[test] fn hidden_groups_hide_what_is_in_them() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut g = obj("g", "group", 0.0, 0.0, json!({})); g.hidden = true; let mut inner = obj("inner", "group", 0.0, 0.0, json!({})); inner.group = "g".into(); let mut t1 = obj("t1", "tree", 0.0, 0.0, json!({})); t1.group = "inner".into(); let mut t2 = obj("t2", "tree", 5.0, 0.0, json!({})); t2.group = "g".into(); let doc = Doc { objects: vec![t1, t2, inner, g, obj("t3", "tree", 10.0, 0.0, json!({}))], ..Default::default() }; let b = r.build("site", 0, &cfg(), &doc).unwrap(); // groups draw nothing and warn about nothing assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); assert_eq!(sprites_of(&b, "g") + sprites_of(&b, "inner"), 0); assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 0); assert_eq!(sprites_of(&b, "t3"), 1); let mut shown = doc.clone(); shown.objects[3].hidden = false; let b = r.build("site", 0, &cfg(), &shown).unwrap(); assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 2); } /// The windows of every route mover piece of object `id`: (piece sprite, windows). fn travel_windows(b: &crate::Built, id: &str) -> Vec> { fn walk(p: &isokit_core::Prim, out: &mut Vec>) { match p { isokit_core::Prim::Travel { wins, .. } => out.push(wins.clone()), isokit_core::Prim::Group { items, .. } => items.iter().for_each(|q| walk(q, out)), _ => {} } } let mut out = vec![]; for s in b.scene.layers.iter().flat_map(|l| l.sprites.iter()) { if s.obj.as_deref() == Some(id) { s.prims.iter().for_each(|p| walk(p, &mut out)); } } out } #[test] fn route_movers_show_once_each_at_every_moment() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut cars = obj( "cars", "route", 0.0, 0.0, json!({"kind": "car", "n": 4, "closed": true}), ); cars.pts = Some(vec![ [-25.0, -10.0], [25.0, -10.0], [25.0, 10.0], [-25.0, 10.0], ]); let mut walk = obj( "walk", "route", 0.0, 0.0, json!({"kind": "person", "n": 3, "colour": "hivis"}), ); walk.pts = Some(vec![[-15.0, -4.0], [5.0, -4.0], [12.0, 3.0]]); let mut deer = obj( "deer", "route", 30.0, 20.0, json!({"kind": "deer", "n": 2, "path": false}), ); deer.rot = 30.0; deer.pts = Some(vec![[-6.0, 0.0], [6.0, 0.0]]); // a loop that doubles back twice (a spur up to a stop and back, a U-turn at the west end): // each way keeps to its side, so the strips never overlap let mut spur = obj( "spur", "route", 0.0, 40.0, json!({"kind": "bus", "n": 2, "closed": true}), ); spur.pts = Some(vec![[-20.0, 0.0], [20.0, 0.0], [20.0, 15.0], [20.0, 0.0]]); let doc = Doc { objects: vec![ cars, walk, deer, spur, obj("t1", "tree", 0.0, -14.0, json!({})), ], ..Default::default() }; for view in 0..4u8 { let b = r.build("site", view, &cfg(), &doc).unwrap(); assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); let out = render(&b.scene, view, &RenderOpts::default()); assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); // one