//! 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, ¬ify); 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, ¬ify, ); 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, address: SocketAddr, api: Option>, } 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 = ¤t.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::(&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::(&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, ¬ify); 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(¤t); } assert_eq!(worker.repl.as_ref().unwrap().0, 3); namespace.write().unwrap().clear().unwrap(); worker.refresh_viewport(&namespace, ¬ify); 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, ¬ify, true); let value = worker .repl .as_mut() .unwrap() .1 .execute("count.value", "app.main") .unwrap() .value; assert_eq!(value, "0"); worker.refresh_viewport(&namespace, ¬ify); 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 = ¤t.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::(&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 = ¤t.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::(&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 = ¤t.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::(&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::().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 = ¤t.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::(&response.2).unwrap()["value"] .as_str() .unwrap() .parse::() .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()); }