Deploy web Studio / deploy (push) Successful in 1m4s
datacentre.rhai (palette groups Data centre, Cooling, Fire safety, Power): - dc_building: multi-storey shell (storeys of storey_h), walls in pieces, slabs, roof with parapet, louvres, front door and loading dock; levels cut it open storey by storey - raised_floor; rack_row and dc_pod (cabinets by U, width, depth, equipment, fill, doors, LEDs; in-row coolers; cold / hot aisle containment with vent tiles, roof, end doors, ceiling duct, airflow); dc_cage for colocation - crah, chiller (air-cooled, dry, adiabatic), cooling_tower, pipe_run - smoke_detectors (grid + coverage), fire_pipe (VESDA, sprinkler, gas), gas_bank (Novec 1230, FM-200, Inergen sized for its halls), fire_device (panels, call points, abort switches, beacons, extinguishers) - power_cab (UPS, battery, PDU, switchgear, RPP), busway, cable_tray, transformer - tooltips sum IT load, kW/m2, cooling redundancy per storey, UPS load, battery runtime, plant cooling and agent quantity (warning when short) Engine and stdlib: - part_hidden(o) in house.rhai: parts on closed storeys inside a dc_building are not drawn (they only made sort cycles under the slab above); house parts, furniture and people use it. Existing examples render byte-identical. - overhead services sort in their own "ceiling" layer with tight boxes - sort records which sprites each broken cycle involved; isokit check names them (Rendered.cycle_at) examples/datacentre (three storeys and a roof plant), Data centre demo in the web Studio, tests, SCRIPTING/README/ROADMAP, MCP instructions.
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
//! Integration tests on the demo project: load, render every view, export,
|
|
//! check, document operations through a Workspace, MCP round trips.
|
|
|
|
use isokit_project::mcp;
|
|
use isokit_project::ops::Op;
|
|
use isokit_project::workspace::Workspace;
|
|
use isokit_project::{check, page, Project};
|
|
use serde_json::{json, Value};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
fn demo() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo")
|
|
}
|
|
|
|
/// A throwaway copy of the demo (tests edit it).
|
|
fn copy_demo(name: &str) -> PathBuf {
|
|
let dst = std::env::temp_dir().join(format!("isokit-test-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dst);
|
|
fn cp(a: &Path, b: &Path) {
|
|
std::fs::create_dir_all(b).unwrap();
|
|
for e in std::fs::read_dir(a).unwrap().flatten() {
|
|
let p = e.path();
|
|
let n = e.file_name();
|
|
if p.is_dir() {
|
|
if n != "dist" {
|
|
cp(&p, &b.join(n));
|
|
}
|
|
} else {
|
|
std::fs::copy(&p, b.join(n)).unwrap();
|
|
}
|
|
}
|
|
}
|
|
cp(&demo(), &dst);
|
|
dst
|
|
}
|
|
|
|
#[test]
|
|
fn demo_renders_every_view_cleanly() {
|
|
let p = Project::load(demo()).unwrap();
|
|
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
|
|
for l in p.manifest.levels() {
|
|
for rot in 0..4 {
|
|
let v = p.render(&l.id, rot, None, false).unwrap();
|
|
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
|
|
assert!(!v.html.contains("NaN"));
|
|
assert!(
|
|
v.nodes < p.manifest.build.budget,
|
|
"{}/{rot}: {} nodes",
|
|
l.id,
|
|
v.nodes
|
|
);
|
|
for o in p.doc.objects.iter().filter(|o| o.kind != "group") {
|
|
assert!(
|
|
v.boxes.contains_key(&o.id),
|
|
"{} not drawn in {}/{rot}",
|
|
o.id,
|
|
l.id
|
|
);
|
|
assert!(v.info.contains_key(&o.id), "{} has no tooltip", o.id);
|
|
}
|
|
// the script's sign and the project prefab are there
|
|
assert!(v.html.contains("data-id=\"sign\""));
|
|
assert!(v.html.contains("data-obj=\"kiosk\""));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn demo_passes_the_check() {
|
|
let p = Project::load(demo()).unwrap();
|
|
let f = check::check(&p);
|
|
assert!(f.is_empty(), "{f:?}");
|
|
}
|
|
|
|
/// The house, factory, streets and country demos: every view draws every object without warnings
|
|
/// or depth-sort cycles (walls and belts must not overlap), and the projects pass the check.
|
|
#[test]
|
|
fn house_demo_renders_every_view_cleanly() {
|
|
example_renders_every_view_cleanly("house");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_demo_renders_every_view_cleanly() {
|
|
example_renders_every_view_cleanly("factory");
|
|
}
|
|
|
|
#[test]
|
|
fn streets_demo_renders_every_view_cleanly() {
|
|
example_renders_every_view_cleanly("streets");
|
|
}
|
|
|
|
#[test]
|
|
fn country_demo_renders_every_view_cleanly() {
|
|
example_renders_every_view_cleanly("country");
|
|
}
|
|
|
|
#[test]
|
|
fn datacentre_demo_renders_every_view_cleanly() {
|
|
let p = Project::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/datacentre"))
|
|
.unwrap();
|
|
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
|
|
// storeys of 5 m; the building covers |x| <= 21, |y| <= 13 and has three
|
|
// storeys, the plant on its roof (storey 3)
|
|
let storey = |z: f64| ((z + 0.5) / 5.0).floor() as i64;
|
|
for l in p.manifest.levels() {
|
|
let k: i64 = match l.id.as_str() {
|
|
"gf" => 0,
|
|
"1f" => 1,
|
|
"2f" => 2,
|
|
_ => -1,
|
|
};
|
|
for rot in 0..4 {
|
|
let v = p.render(&l.id, rot, None, false).unwrap();
|
|
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
|
|
assert_eq!(v.cycles, 0, "{}/{rot}: {:?}", l.id, v.cycle_at);
|
|
assert!(!v.html.contains("NaN"));
|
|
for o in p.doc.objects.iter().filter(|o| o.kind != "group") {
|
|
let s = storey(o.z);
|
|
let ground = o.kind == "site" || o.kind == "area";
|
|
let inside = !ground && o.x.abs() < 21.0 && o.y.abs() < 13.0;
|
|
// the cut storey shows, closed storeys inside the shell and storeys above do not
|
|
let shown = o.kind == "dc_building" || !inside || s == k || (k < 0 && s >= 3);
|
|
assert_eq!(
|
|
v.boxes.contains_key(&o.id),
|
|
shown,
|
|
"{} on {}/{rot}",
|
|
o.id,
|
|
l.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert!(check::check(&p).is_empty(), "{:?}", check::check(&p));
|
|
}
|
|
|
|
fn example_renders_every_view_cleanly(name: &str) {
|
|
let p = Project::load(
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../examples")
|
|
.join(name),
|
|
)
|
|
.unwrap();
|
|
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
|
|
for l in p.manifest.levels() {
|
|
for rot in 0..4 {
|
|
let v = p.render(&l.id, rot, None, false).unwrap();
|
|
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
|
|
assert_eq!(v.cycles, 0, "{}/{rot}: depth-sort cycles", l.id);
|
|
assert!(!v.html.contains("NaN"));
|
|
for o in p.doc.objects.iter().filter(|o| o.kind != "group") {
|
|
assert!(
|
|
v.boxes.contains_key(&o.id),
|
|
"{} not drawn in {}/{rot}",
|
|
o.id,
|
|
l.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert!(check::check(&p).is_empty(), "{:?}", check::check(&p));
|
|
}
|
|
|
|
#[test]
|
|
fn static_page_and_svg_export() {
|
|
let p = Project::load(demo()).unwrap();
|
|
let html = page::static_page(&p).unwrap();
|
|
assert!(
|
|
html.contains("IsoKit.mount") && html.contains("\"site/1\"") && html.contains("\"gf/3\"")
|
|
);
|
|
assert!(
|
|
!html.contains("/*ISOKIT_"),
|
|
"a placeholder was left in the page"
|
|
);
|
|
assert!(
|
|
html.contains(".iso-brick { --b: var(--iso-brick); }"),
|
|
"project theme merged"
|
|
);
|
|
let svg = page::svg(&p, "site", 1, true).unwrap();
|
|
assert!(
|
|
svg.starts_with("<svg") && svg.contains("<style>") && svg.contains("iso-wall")
|
|
|| svg.contains("iso-render")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_edits_save_and_undo() {
|
|
let dir = copy_demo("ws");
|
|
let mut ws = Workspace::open(&dir).unwrap();
|
|
let n = ws.project.doc.objects.len();
|
|
let id = ws
|
|
.apply(
|
|
serde_json::from_value::<Op>(json!({"op": "add", "type": "tree", "x": 1, "y": 2}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap()
|
|
.id
|
|
.unwrap();
|
|
// saved to disk
|
|
let on_disk = Project::load(&dir).unwrap();
|
|
assert_eq!(on_disk.doc.objects.len(), n + 1);
|
|
assert!(on_disk.doc.get(&id).is_some());
|
|
ws.undo().unwrap();
|
|
assert_eq!(Project::load(&dir).unwrap().doc.objects.len(), n);
|
|
ws.redo().unwrap();
|
|
assert_eq!(ws.project.doc.objects.len(), n + 1);
|
|
// an outside edit (another process) is picked up and can be undone
|
|
let mut d = on_disk.doc.clone();
|
|
d.objects.retain(|o| o.kind != "lamp");
|
|
std::fs::write(
|
|
dir.join("scene.json"),
|
|
serde_json::to_string_pretty(&d).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(ws.refresh());
|
|
assert!(ws.project.doc.objects.iter().all(|o| o.kind != "lamp"));
|
|
ws.undo().unwrap();
|
|
assert!(ws.project.doc.objects.iter().any(|o| o.kind == "lamp"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
fn call(ws: &mut Workspace, id: u64, name: &str, args: Value) -> (bool, Value) {
|
|
let r = mcp::handle(ws, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})).unwrap();
|
|
let res = &r["result"];
|
|
let text = res["content"][0]["text"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
(
|
|
res["isError"].as_bool().unwrap_or(true),
|
|
serde_json::from_str(&text).unwrap_or(Value::String(text)),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_round_trip() {
|
|
let dir = copy_demo("mcp");
|
|
let mut ws = Workspace::open(&dir).unwrap();
|
|
let init = mcp::handle(&mut ws, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).unwrap();
|
|
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
|
assert!(mcp::handle(
|
|
&mut ws,
|
|
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
)
|
|
.is_none());
|
|
let list = mcp::handle(
|
|
&mut ws,
|
|
&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
|
|
)
|
|
.unwrap();
|
|
let names: Vec<&str> = list["result"]["tools"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|t| t["name"].as_str().unwrap())
|
|
.collect();
|
|
for t in [
|
|
"project_info",
|
|
"list_prefabs",
|
|
"add_object",
|
|
"update_object",
|
|
"render_view",
|
|
"check_project",
|
|
"build_html",
|
|
] {
|
|
assert!(names.contains(&t), "{t}");
|
|
}
|
|
let (err, info) = call(&mut ws, 3, "project_info", json!({}));
|
|
assert!(!err && info["objects"].as_u64().unwrap() > 10);
|
|
let (err, pf) = call(&mut ws, 4, "list_prefabs", json!({"type": "building"}));
|
|
assert!(
|
|
!err && pf["props"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|p| p["key"] == "floors")
|
|
);
|
|
let (err, added) = call(
|
|
&mut ws,
|
|
5,
|
|
"add_object",
|
|
json!({"type": "building", "x": 20, "y": 15, "props": {"w": 8, "d": 6, "floors": 1}, "name": "Store"}),
|
|
);
|
|
assert!(!err, "{added}");
|
|
let id = added["id"].as_str().unwrap().to_string();
|
|
let b = added["object"]["box"].as_array().unwrap();
|
|
assert_eq!(
|
|
(b[0].as_f64().unwrap(), b[3].as_f64().unwrap()),
|
|
(16.0, 24.0)
|
|
);
|
|
let (err, upd) = call(
|
|
&mut ws,
|
|
6,
|
|
"update_object",
|
|
json!({"id": id, "rot": 90, "props": {"roof": "gable"}}),
|
|
);
|
|
assert!(!err, "{upd}");
|
|
assert_eq!(upd["object"]["rot"], 90.0);
|
|
let (err, bad) = call(
|
|
&mut ws,
|
|
7,
|
|
"update_object",
|
|
json!({"id": id, "props": {"floors": 99}}),
|
|
);
|
|
assert!(err && bad.as_str().unwrap().contains("outside"), "{bad}");
|
|
let (err, ops) = call(
|
|
&mut ws,
|
|
8,
|
|
"apply_ops",
|
|
json!({"ops": [
|
|
{"op": "add", "type": "tree", "x": 0, "y": 18},
|
|
{"op": "add", "type": "tree", "x": 4, "y": 18},
|
|
{"op": "remove", "id": "tree-1"}
|
|
]}),
|
|
);
|
|
assert!(!err, "{ops}");
|
|
let (err, view) = call(
|
|
&mut ws,
|
|
9,
|
|
"render_view",
|
|
json!({"level": "gf", "rotation": 2}),
|
|
);
|
|
assert!(!err && view["boxes"].get(&id).is_some(), "{view}");
|
|
let (err, svg) = call(&mut ws, 10, "render_view", json!({"format": "svg"}));
|
|
assert!(!err && svg.as_str().unwrap().starts_with("<svg"));
|
|
let (err, chk) = call(&mut ws, 11, "check_project", json!({}));
|
|
assert!(!err && chk["ok"] == true, "{chk}");
|
|
let (err, built) = call(&mut ws, 12, "build_html", json!({}));
|
|
assert!(!err && Path::new(built["path"].as_str().unwrap()).exists());
|
|
let (err, _) = call(&mut ws, 13, "undo", json!({}));
|
|
assert!(!err);
|
|
assert!(
|
|
ws.project.doc.get("tree-1").is_some(),
|
|
"undo brings back the batch"
|
|
);
|
|
let unknown = mcp::handle(
|
|
&mut ws,
|
|
&json!({"jsonrpc": "2.0", "id": 14, "method": "nope"}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(unknown["error"]["code"], -32601);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffolded_project_works() {
|
|
let dir = std::env::temp_dir().join(format!("isokit-test-new-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
isokit_project::scaffold(&dir, "New site").unwrap();
|
|
let p = Project::load(&dir).unwrap();
|
|
assert_eq!(p.meta().title, "New site");
|
|
let f = check::check(&p);
|
|
assert!(f.is_empty(), "{f:?}");
|
|
assert!(isokit_project::scaffold(&dir, "again").is_err());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|