Files
endorphin-tessera-gui/src/runtime/api/tests.rs
T

888 lines
34 KiB
Rust

//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
//!
use super::*;
use std::{
io::Write,
net::{SocketAddr, TcpStream},
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
#[test]
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
let (_, mut picture) = endor_host::ReplSession::load(
include_str!("../../../examples/windows.endor"),
"app.main",
1,
150,
)
.unwrap();
// 把旧文档标成无活动 VM 的保留投影;编号相同也不能据此识别当前资源。
picture.frame.session = 0;
for view in picture.windows.values_mut() {
view.frame.session = 0;
}
let mut fresh = endor_host::ReplSession::new(151).unwrap();
assert!(
fresh
.execute("pending=Window()\nui.window().resize(0,600)", "app.main")
.is_err()
);
assert!(fresh.accepts_window(2));
let namespace = Arc::new(RwLock::new(NamespaceState {
document: picture,
submission: 1,
runtime_session: 151,
..NamespaceState::default()
}));
let mut worker = Worker {
repl: Some((1, fresh)),
next_session: 151,
};
let notify: Notify = Arc::new(|| {});
namespace.write().unwrap().window_viewports.insert(
2,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
);
worker.refresh_viewport(&namespace, &notify);
assert_eq!(
namespace.read().unwrap().document.windows[&2].frame.session,
0
);
let response = worker.close_inactive_window(
&crate::events::Input {
window: 2,
session: 0,
layout_version: 0,
mount: 0,
kind: "window-close".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
},
1,
&namespace,
&notify,
);
assert_eq!(response.status_code(), StatusCode(200));
assert!(namespace.read().unwrap().document.windows.is_empty());
let runtime = &mut worker.repl.as_mut().unwrap().1;
assert_eq!(runtime.id, 151);
assert!(runtime.accepts_window(2));
assert_eq!(
runtime.execute("pending.id()", "app.main").unwrap().value,
"2"
);
}
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
struct TestServer {
server: Arc<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
impl TestServer {
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
let mut stream = TcpStream::connect(self.address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(10)))
.unwrap();
write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
(status, headers.to_owned(), body.to_owned())
}
}
impl Drop for TestServer {
/// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。
fn drop(&mut self) {
self.server.unblock();
if let Some(api) = self.api.take() {
let _ = api.join();
}
}
}
#[test]
/// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。
/// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"class Tracked: Page {
calls: Object
function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) }
}
calls=ui.state(0)
first=Tracked(calls)
first.title="first"
first.render(TextBlock("first"))
second=Page()
second.title="second"
second.render(TextBlock("second"))
first.activate()"#;
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
let input = {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame.tabs[0].id,
kind: "tab-drop".into(),
point: None,
press_point: None,
delta: None,
screen_position: None,
tab_drop: Some(crate::events::TabDrop {
window: 1,
layout_version: frame.layout_version,
index: 2,
}),
}
};
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
let body = serde_json::to_string(&input).unwrap();
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value");
let value: serde_json::Value = serde_json::from_str(&response.2).unwrap();
assert_eq!(value["value"], "1");
assert_eq!(
namespace.read().unwrap().document.frame.tabs[1].title,
"first"
);
assert_eq!(
client
.request("POST", "/v1/namespaces/app.main/events", &body)
.0,
409
);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
fn background_failure_preserves_its_cause_after_rejected_console_input() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"class Failure {
function stop(self: Failure): Unit { ui.window().resize(0,600) }
}
failure=Failure()
page=Page()
page.render(TextBlock("preserved"))
task=ui.after(ui,"failure",0,failure.stop)"#;
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
let repl = "/v1/namespaces/app.main/repl";
assert_eq!(client.request("POST", repl, "42").0, 422);
let fault = namespace
.read()
.unwrap()
.document
.frame
.fault
.clone()
.unwrap();
assert!(fault.contains("window size"), "{fault}");
assert!(fault.contains("stop"), "{fault}");
assert_eq!(client.request("POST", repl, "42").0, 422);
assert_eq!(
namespace.read().unwrap().document.frame.fault.as_ref(),
Some(&fault)
);
}
#[test]
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let repl = "/v1/namespaces/app.main/repl";
let code = "/v1/namespaces/app.main/code";
// 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
assert_eq!(created.0, 200, "{}", created.2);
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
assert_eq!(created["rendered"], false);
assert_eq!(created["revision"], 0);
let read: serde_json::Value =
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
assert_eq!(read["value"], created["value"]);
assert_eq!(
client.request("POST", repl, "ui.page().render(title)").0,
200
);
let snapshot = client.request("GET", code, "");
assert!(
snapshot
.1
.to_lowercase()
.contains("x-tessera-source-kind: repl-transcript")
);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("DELETE", repl, "").0, 200);
assert_eq!(client.request("GET", code, "").2, snapshot.2);
assert_eq!(client.request("POST", repl, "title").0, 422);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
assert_eq!(
client
.request("PUT", code, include_str!("../../../examples/page.endor"))
.0,
200
);
assert_eq!(client.request("POST", repl, "retained").0, 422);
assert_eq!(namespace.read().unwrap().revision, 2);
}
#[test]
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let notifications = Arc::new(AtomicUsize::new(0));
let counter = notifications.clone();
let notify: Notify = Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../../../examples/page.endor");
let (status, _, guide) = client.request("GET", "/agent", "");
assert_eq!(status, 200);
assert!(guide.contains("let myui = ui.page()"));
let (status, _, body) = client.request("PUT", route, example);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["revision"],
1
);
let (status, headers, source) = client.request("GET", route, "");
assert_eq!(status, 200);
assert!(headers.to_lowercase().contains("x-tessera-revision: 1"));
assert_eq!(source, example);
let (status, _, body) =
client.request("PUT", route, "function main(): View { return missing }");
assert_eq!(status, 422, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["diagnostics"][0]["line"],
1
);
assert_eq!(client.request("GET", route, "").2, example);
let second = example.replace("Hello Endor", "Second");
assert_eq!(client.request("PUT", route, &second).0, 200);
assert_eq!(namespace.read().unwrap().revision, 2);
assert_eq!(client.request("DELETE", route, "").0, 200);
assert_eq!(client.request("GET", route, "").2, "");
assert!(namespace.read().unwrap().document.frame.commands.is_empty());
assert_eq!(client.request("PUT", route, example).0, 200);
assert_eq!(namespace.read().unwrap().revision, 4);
assert_eq!(
client
.request("PUT", "/v1/namespaces/other/code", example)
.0,
404
);
assert_eq!(notifications.load(Ordering::SeqCst), 4);
}
#[test]
/// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。
/// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
let (runtime, document) = endor_host::ReplSession::load(
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
"app.main",
1,
277,
)
.unwrap();
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
submission: 2,
runtime_session: runtime.id,
..NamespaceState::default()
}));
let mut worker = Worker {
repl: Some((1, runtime)),
next_session: 277,
};
let notify: Notify = Arc::new(|| {});
worker.refresh_viewport(&namespace, &notify);
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
assert_eq!(
worker
.repl
.as_mut()
.unwrap()
.1
.execute("retained.value", "app.main")
.unwrap()
.value,
"9"
);
{
let mut current = namespace.write().unwrap();
current.submission = 3;
worker.preserve_live_owner(&current);
}
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
namespace.write().unwrap().clear().unwrap();
worker.refresh_viewport(&namespace, &notify);
assert!(worker.repl.is_none());
}
#[test]
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
fn http_delete_remains_available_and_invalidates_queued_put() {
use std::{
sync::{Mutex, mpsc},
time::Instant,
};
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
let release_rx = Mutex::new(release_rx);
let calls = AtomicUsize::new(0);
let notify: Notify = Arc::new(move || {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
entered_tx.send(()).unwrap();
release_rx.lock().unwrap().recv().unwrap();
}
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 析构守卫在 scoped 线程 join 前释放阻塞通知;即使断言 panic 也不能让 worker 永久等通道。
// 持有释放信号的发送端;离开作用域即通知 worker 继续,包括异常展开路径。
struct Release(mpsc::Sender<()>);
impl Drop for Release {
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
fn drop(&mut self) {
let _ = self.0.send(());
}
}
thread::scope(|scope| {
let release = Release(release_tx);
let client = &client;
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../../../examples/page.endor");
let first = scope.spawn(move || client.request("PUT", route, example));
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let pending = scope.spawn(move || client.request("PUT", route, example));
let deadline = Instant::now() + Duration::from_secs(10);
while namespace.read().unwrap().submission < 2 {
assert!(Instant::now() < deadline, "second PUT was not admitted");
thread::sleep(Duration::from_millis(5));
}
assert_eq!(client.request("GET", route, "").2, example);
assert_eq!(client.request("DELETE", route, "").0, 200);
// DELETE 已同步清空文档并提升提交身份,现在解除第一请求通知阻塞,让排队候选观察到失效。
drop(release);
assert_eq!(first.join().unwrap().0, 200);
assert_eq!(pending.join().unwrap().0, 409);
assert_eq!(client.request("GET", route, "").2, "");
assert_eq!(namespace.read().unwrap().revision, 2);
});
}
#[test]
/// 检查 code 路径解析只接受完整的单段 namespace;缺少后缀和嵌套斜线均返回 None。
fn extracts_namespace_only_from_code_routes() {
assert_eq!(
namespace_from_path("/v1/namespaces/app.main/code"),
Some("app.main")
);
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
}
#[test]
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
fn guide_contains_only_code_crud_routes() {
let guide = agent_guide(&NamespaceState::default());
assert!(guide.contains("Code CRUD"));
assert!(guide.contains("let myui = ui.page()"));
assert!(!guide.contains("export scene"));
assert!(guide.contains("PUT http://127.0.0.1:4737"));
assert!(!guide.contains("display_list"));
}
#[test]
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
let source = r#"
class Action {
count: Object
function fire(self: Action): Unit { self.count.increment() }
}
count=ui.state(0)
action=Action(count)
ui.after(ui,"ready",0,action.fire)
page=Page()
page.render(TextBlock("input priority"))
"#;
let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap();
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
submission: 1,
runtime_session: 801,
..NamespaceState::default()
}));
let mut worker = Worker {
repl: Some((1, runtime)),
next_session: 801,
};
let notify: Notify = Arc::new(|| {});
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
worker.refresh_viewport_mode(&namespace, &notify, true);
let value = worker
.repl
.as_mut()
.unwrap()
.1
.execute("count.value", "app.main")
.unwrap()
.value;
assert_eq!(value, "0");
worker.refresh_viewport(&namespace, &notify);
let runtime = &mut worker.repl.as_mut().unwrap().1;
for _ in 0..64 {
if !runtime.has_pending_refresh() {
break;
}
runtime
.resize_windows_incremental(&std::collections::BTreeMap::new(), false)
.unwrap();
}
let value = runtime.execute("count.value", "app.main").unwrap().value;
assert_eq!(value, "1");
}
#[test]
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 600.0,
height: 400.0,
}),
..NamespaceState::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"
count=ui.state(0)
button=Button("world")
button.onClick(count.increment)
button.x=-160
button.y=-80
page=Page()
page.mode="canvas"
page.render(button)
"#;
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
let frame = &namespace.read().unwrap().document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: 0,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
screen_position: None,
tab_drop: None,
})
.unwrap()
};
let event_path = "/v1/namespaces/app.main/events";
let pan = camera("page-pan", [-240.0, -160.0], None);
let response = client.request("POST", event_path, &pan);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
namespace.read().unwrap().document.frame.surface.pan,
crate::geometry::Point::new(-240.0, -160.0)
);
assert_eq!(client.request("POST", event_path, &pan).0, 409);
let response = client.request(
"POST",
event_path,
&camera("page-zoom", [2.0, 0.0], Some([120.0, 90.0])),
);
assert_eq!(response.0, 200, "{}", response.2);
{
let current = namespace.read().unwrap();
assert_eq!(current.document.frame.surface.zoom, 2.0);
assert_eq!(
current.document.frame.surface.pan,
crate::geometry::Point::new(-180.0, -115.0)
);
}
let input = {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
let mount = frame.mounts.iter().find(|m| m.interactive).unwrap();
assert!(mount.x < 0.0 && mount.y < 0.0);
crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: Some([-150.0, -70.0]),
press_point: Some([-150.0, -70.0]),
delta: None,
screen_position: None,
tab_drop: None,
}
};
let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap());
assert_eq!(response.0, 200, "{}", response.2);
let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value");
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
"1"
);
let response = client.request(
"POST",
event_path,
&camera("page-zoom", [0.0, 0.0], Some([120.0, 90.0])),
);
assert_eq!(response.0, 400);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
/// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。
/// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 600.0,
height: 400.0,
}),
..NamespaceState::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"
content = Button("inside")
content.x = 40
content.y = 20
scene = Scene2D(content)
view = CanvasViewport(200, 100, scene)
view.borderLeft = 10
page = Page()
page.render(Column(20, 0, view))
"#;
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport)
.unwrap()
.id,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
screen_position: None,
tab_drop: None,
})
.unwrap()
};
let path = "/v1/namespaces/app.main/events";
let pan = camera("viewport-pan", [20.0, -10.0], None);
let response = client.request("POST", path, &pan);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(client.request("POST", path, &pan).0, 409);
let response = client.request(
"POST",
path,
&camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])),
);
assert_eq!(response.0, 200, "{}", response.2);
for (expression, expected) in [
("view.camera.x", "45"),
("view.camera.y", "10"),
("view.camera.zoom", "2"),
] {
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
expected
);
}
// 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
let response = client.request(
"POST",
path,
&camera("viewport-zoom", [2.0, 0.0], Some(point)),
);
assert_eq!(response.0, 400, "{}", response.2);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
}
#[test]
/// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。
/// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 400.,
height: 300.,
}),
..NamespaceState::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r##"
class PanOnly {
function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) }
}
view = Viewport3D(200,100,Scene3D(Box3D(2,2,2,"#CC4455")))
page = Page()
page.render(view)
"##;
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport)
.unwrap()
.id,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
tab_drop: None,
screen_position: None,
})
.unwrap()
};
let event_path = "/v1/namespaces/app.main/events";
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
let response = client.request("POST", event_path, &zoom);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
let value = |expression: &str| {
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
.as_str()
.unwrap()
.to_owned()
};
assert_eq!(value("view.camera.z"), "2.5");
let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None));
assert_eq!(response.0, 200, "{}", response.2);
let x = value("view.camera.x").parse::<f32>().unwrap();
assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001);
value("view.camera = OrthographicCamera()");
let response = client.request(
"POST",
event_path,
&gesture("scene-zoom", [2., 0.], Some([100., 50.])),
);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(value("view.camera.height"), "2.5");
value("view.controller = PanOnly()");
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(value("view.camera.x"), "7");
let response = client.request(
"POST",
event_path,
&gesture("scene-zoom", [2., 0.], Some([250., 50.])),
);
assert_eq!(response.0, 400);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
value("view.controller = \"none\"");
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
assert_eq!(response.0, 409);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
/// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。
/// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 400.,
height: 300.,
}),
..NamespaceState::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r##"
canvas=CanvasViewport(100,100,Scene2D(Button("UI in a world")))
panel=Panel3D(2,2,canvas)
panel.uiWidth=100
panel.uiHeight=100
panel.rotationY=25
view=Viewport3D(200,200,Scene3D(panel))
page=Page()
page.render(view)
"##;
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport && mount.panel.is_some())
.unwrap()
.id,
kind: kind.into(),
point,
press_point: None,
delta: Some(delta),
tab_drop: None,
screen_position: None,
})
.unwrap()
};
let path = "/v1/namespaces/app.main/events";
assert_eq!(
client
.request("POST", path, &gesture("viewport-pan", [10., 0.], None))
.0,
400
);
let response = client.request(
"POST",
path,
&gesture("viewport-pan", [10., 0.], Some([100., 100.])),
);
assert_eq!(response.0, 200, "{}", response.2);
let value = |expression: &str| {
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
.as_str()
.unwrap()
.parse::<f32>()
.unwrap()
};
let pan = value("canvas.camera.x");
// 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。
assert!(pan > 10.);
assert_eq!(value("view.camera.x"), 0.);
let response = client.request(
"POST",
path,
&gesture("viewport-zoom", [2., 0.], Some([100., 100.])),
);
assert_eq!(response.0, 200, "{}", response.2);
assert!((value("canvas.camera.x") - pan - 25.).abs() < 0.001);
assert_eq!(value("canvas.camera.zoom"), 2.);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}