isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -0,0 +1,267 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
Reference in new issue
Block a user