Files
isokit/crates/isokit-project/tests/demo.rs
T
grabowski 95ea4a8512
Deploy web Studio / deploy (push) Successful in 3m27s
Build your own house: walls, windows, doors, floors, roof, stairs, furniture
House parts (stdlib isokit/house): house_wall drawn along a line, with ends
that adapt at corners, T-junctions and straight runs so no two walls
overlap; window and door that sit on a wall and cut its opening (door
styles panel, glass, double, sliding, opening; swing and hinge by
rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat,
optional solar); stairs.

Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with
chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen
run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp.

Cut-open levels: on gf / 1f the walls in front of a room are cut low
(rooms are found by their floors), the roof becomes an outline on the
floor, and storeys above are left out. A part's storey comes from z
(storey_h, default 3 m).

Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame,
to_world, to_local, local_of. Prefab meta: shape (line / rect), mount,
storey.

Studio: drag to draw lines and rectangles (walls, roads, fences, hedges,
floors, areas, buildings, roofs). The Room tool draws a floor plus walls,
without doubling walls that already stand on an edge. Windows and doors
snap onto walls with a hover preview, and they move, turn, copy and
delete with their wall in one undo step. New parts land on the storey
the view shows. Palette categories collapse, and the palette scrolls
apart from the object list.

The MCP instructions explain house building, and list_prefabs shows
sits_on. examples/house is a furnished bungalow; the web Studio gets a
House demo button. Tests: wall corners and junctions, zero depth-sort
cycles in every view, cut levels, and the house demo in every view;
check_studio covers the room / window / door / storey flow, check_web
the House demo.
2026-10-24 17:39:04 +07:00

294 lines
9.7 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 {
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 demo: every view draws every object without warnings or depth-sort cycles
/// (walls must not overlap), and the project passes the check.
#[test]
fn house_demo_renders_every_view_cleanly() {
let p =
Project::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/house")).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 {
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);
}