//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 //! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。 //! //! Rust 测试框架调用下方 #[test];大多数测试通过 TestServer::request 访问真实 tiny_http 路由和串行 VM worker。 //! 少量测试直接安装 Worker 的活会话以精确触发发布身份边界,仍调用真实 ReplSession 而非模拟对象堆。 //! r# 字符串中的 Endor 是测试夹具,行号/诊断也可能是契约的一部分;注释只加在 Rust 测试逻辑外层。 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() { // 旧运行时真实创建多个窗口,随后只保留其纯数据画面以模拟 Console reset 后的静态投影。 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; } // 新 VM 使用不同 session,但窗口分配器同样从 2 开始,故意形成“同号但不同代”的资源。 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)); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document: picture, submission: 1, runtime_session: 151, ..NamespaceState::default() })); // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, fresh)), next_session: 151, }; // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 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 ); // 关闭信封指向 session=0 的旧投影,不能因新 VM 也有 window=2 就退役它。 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()); // 从 worker 真实活域验证新窗口仍存在,不仅检查画面副本是否被删。 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 以便 Drop 调用 unblock,中断 incoming_requests 等待。 server: Arc, // 每例实际绑定端口,从 server_addr 读取而非硬编码应用默认地址。 address: SocketAddr, // Option 允许 Drop 取出句柄只 join 一次,不从借用的 self 中移动裸 JoinHandle。 api: Option>, } impl TestServer { /// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。 /// 返回状态码、原始头和正文;连接后的 socket 读/写各设 10 秒超时,不是整个请求的统一截止时间。 fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { // 不用直调 handle 代替传输,真实 socket 覆盖 HTTP 接收、正文流和响应编码。 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(); // Connection: close 让响应读到 EOF,超时避免服务端死锁导致整套测试无限卡住。 let mut response = String::new(); stream.read_to_string(&mut response).unwrap(); // 按 HTTP 空行分头/体,保持响应原文供状态头与 JSON/源码两类断言分别检查。 let (headers, body) = response.split_once("\r\n\r\n").unwrap(); // 状态行第二词是状态码,输出与 headers/body 一起成为各测试的三元组接口。 let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); (status, headers.to_owned(), body.to_owned()) } } impl Drop for TestServer { /// 解除服务端 accept 等待并回收 HTTP 接收线程;此句柄不是 VM worker 的 JoinHandle。 fn drop(&mut self) { self.server.unblock(); if let Some(api) = self.api.take() { // 已解除 accept 等待,join 确保本例线程结束;线程异常由具体测试断言报告,不在 Drop 二次 panic。 let _ = api.join(); } } } #[test] /// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。 /// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。 fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // Page 子类在 moveTab 中递增共享计数,既检查实际重排,也能发现业务方法被挂起恢复重复调用。 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()"#; // 完整 PUT 成功后才从实际发布帧构造标签事件,不能使用 AST/标题推导 Tab ID。 let result = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(result.0, 200, "{}", result.2); // 用第一标签原 ID 指定目标插入缝 2,移动后版本应变化,同一正文再发必须过期。 let input = { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 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(); // 第一遍走真实事件路由与源覆盖,应只重排一次,不因 worker 分片重试业务方法。 let response = client.request("POST", "/v1/namespaces/app.main/events", &body); assert_eq!(response.0, 200, "{}", response.2); // 再经持久 Console 读同一共享状态,避免仅凭标签排列看不出重复回调。 let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value"); // value 是 Console JSON 字段中的显示字符串,不是测试自己复制的 Endor 数值。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 零延时任务的源方法触发非法窗口尺寸,产生真正回调故障而非测试直接写 fault 字段。 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)"#; // 源码候选本身先成功;随后 worker 空闲任务阶段才会触发 stop 中的 resize 错误。 let result = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(result.0, 200, "{}", result.2); // 重复 Console 输入会被停用域拒绝,但不能覆盖首次回调真正的 window size/stop 诊断。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 同一 Console 路由跨多个请求复用真实 globals,/code 读的是可见源快照而非内部变量表。 let repl = "/v1/namespaces/app.main/repl"; // 用于确认 reset 后旧画面/源码仍保留,完整 PUT 才切换到新程序。 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); // 将 HTTP 三元组中的正文解析为 API JSON,rendered/revision 与 value 各有独立断言。 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 ); // render 后 GET 应识别为 transcript;reset 只清 VM 绑定,不应抹去此已发布正文。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); // 原子计数观察真实文档发布通知,不把 HTTP Network 观察更新混入这个断言。 let notifications = Arc::new(AtomicUsize::new(0)); // move 闭包持克隆 Arc,测试仍可通过原 Arc 在请求完成后读累计次数。 let counter = notifications.clone(); // GUI 被计数器替代,实际 worker/发布路径保持不变;只有有效发布才应递增。 let notify: Notify = Arc::new(move || { counter.fetch_add(1, Ordering::SeqCst); }); // 装入计数通知,下面 PUT/DELETE 的总 revision 与通知数必须保持一致。 let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 一个固定合法 namespace 用于全部 CRUD,最后另测错误 namespace 不应创建新域。 let route = "/v1/namespaces/app.main/code"; // 使用实际交付示例,成功 PUT 的 GET 原文应与它逐字相同。 let example = include_str!("../../../examples/page.endor"); // 指南 GET 只能观察状态,不能产生文档 revision 或运行示例。 let (status, _, guide) = client.request("GET", "/agent", ""); assert_eq!(status, 200); assert!(guide.contains("let myui = ui.page()")); // 首次成功 PUT 产生 revision 1,响应 JSON 的版本应对应共享状态。 let (status, _, body) = client.request("PUT", route, example); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["revision"], 1 ); // GET 同时检查版本头与实际源正文,不只确认 HTTP 200。 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); // main 引用未定义名字使候选失败;422 必须保留第 1 行来源且不覆盖前面成功源,断言不区分编译/执行阶段。 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); // 最小可见文字改动生成第二个有效候选;失败候选没有占用 revision,故此时应为 2。 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() { // 真实旧 VM 保留可读 retained 状态,新的 submission=2 模拟接受但尚未成功构建的 PUT。 let (runtime, document) = endor_host::ReplSession::load( "retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))", "app.main", 1, 277, ) .unwrap(); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document, submission: 2, runtime_session: runtime.id, ..NamespaceState::default() })); // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, runtime)), next_session: 277, }; // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 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" ); { // 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。 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, }; // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); // 第一通道通知主测试“worker 已进入首次发布通知”,随后才可靠地安排第二 PUT。 let (entered_tx, entered_rx) = mpsc::channel(); // 第二通道让作用域守卫无条件解除 worker 阻塞,避免测试 panic 后线程 join 永远挂起。 let (release_tx, release_rx) = mpsc::channel(); // 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。 let release_rx = Mutex::new(release_rx); // 只阻塞第一个通知,DELETE 自身的新通知必须仍能正常返回,不依赖固定 sleep 猜时序。 let calls = AtomicUsize::new(0); // 此处故意制造受控阻塞;生产通知只投递 GUI 事件,没有这个测试通道等待。 let notify: Notify = Arc::new(move || { if calls.fetch_add(1, Ordering::SeqCst) == 0 { // 信号先通知测试再等待释放,保证下面读 GET/DELETE 时 worker 确在有界暂停点。 entered_tx.send(()).unwrap(); release_rx.lock().unwrap().recv().unwrap(); } }); // 用真实 HTTP 接收线程证明 VM worker 被阻塞时,GET/DELETE 仍不会被同一队列堵住。 let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 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) { // 最佳努力释放:接收端已停止时发送失败无需让析构再 panic。 let _ = self.0.send(()); } } thread::scope(|scope| { // guard 在作用域返回或断言展开时都释放,随后 scoped 线程才能可靠 join。 let release = Release(release_tx); // 两个请求线程只借同一客户端地址/服务生命周期,request 每次自己建立 TCP 连接。 let client = &client; // 全部操作指同一资源,DELETE 的 epoch 变化必须使排队第二个 PUT 失效。 let route = "/v1/namespaces/app.main/code"; // 首尾都用同一有效源码,第二 PUT 的 409 不能归因于源码编译失败。 let example = include_str!("../../../examples/page.endor"); // 首次发布将触发通知阻塞;在接到 entered 之前不推断它已经执行到提交点。 let first = scope.spawn(move || client.request("PUT", route, example)); entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); // 第二 PUT 由 HTTP 接收线程接受后在 VM 队列等待,source 与第一条完全相同。 let pending = scope.spawn(move || client.request("PUT", route, example)); // 明确截止时间限制接受序号观察,不把后台线程未调度当作已经过期或成功。 let deadline = Instant::now() + Duration::from_secs(10); while namespace.read().unwrap().submission < 2 { // 轮询真实 accepted submission,确认第二请求已入队后才发 DELETE,消除盲目等待竞态。 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 用语。 /// 这些 contains 断言不执行示例,也不证明指南中的全部接口、预算和调度描述都与实现一致。 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() { // ready 定时器会把共享 count 从 0 递增,前台模式不应提前消费它,因此提供明确可观察顺序。 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")) "#; // 同步加载初始画面但不轮询就绪任务,下面才能单独比较前台/空闲两种 worker 行为。 let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap(); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document, submission: 1, runtime_session: 801, ..NamespaceState::default() })); // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, runtime)), next_session: 801, }; // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 let notify: Notify = Arc::new(|| {}); // 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。 worker.refresh_viewport_mode(&namespace, ¬ify, true); // 从同一真实 globals 读取 count,若前台边界抢跑 callback 这里会错误地变成 1。 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 { // 有界排空实际 PendingRefresh,退出条件来自运行时状态而非固定猜测需要几帧。 if !runtime.has_pending_refresh() { break; } runtime .resize_windows_incremental(&std::collections::BTreeMap::new(), false) .unwrap(); } // 完成空闲推进后 count 应恰为 1,既验证有交付,也排除挂起恢复重复调用。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 600.0, height: 400.0, }), ..NamespaceState::default() })); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 按钮放在负世界坐标,主 Page 采用兼容 canvas 模式,事件必须经相机映射而不能只接非负外框位置。 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); // 每次生成时读取新版本;只有显式保存的 pan 原文用于随后重放过期请求。 let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { // Page 级相机要求 mount=0,不能把内容按钮的 Mount ID 当作整页导航目标。 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() }; // 真正 HTTP /events 不享受原生版本追随,因此旧正文重发应该是 409。 let event_path = "/v1/namespaces/app.main/events"; // 负 pan 把负世界位置移入视野,增量不能被当作非负窗口尺寸拒绝。 let pan = camera("page-pan", [-240.0, -160.0], None); // 第一次合法平移改变实际 Surface;再发同一正文则验证严格版本边界。 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); // 倍率 2 和非原点锚点共同决定新 pan,后面的精确期望检查保持锚点世界位置的公式。 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); { // 一次发布读锁内同时断言 zoom 和 pan,不能混读缩放前后不同版本。 // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 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) ); } // click 使用实际交互出现和世界负坐标,验证缩放后目标解析仍保留负坐标语义。 let input = { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; // 找真正按钮出现而非 Page 根,确认布局几何本身保留负世界位置。 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, } }; // 发送一个 click 信封,point/press_point 同点供运行时校验按下与释放;没有模拟两次原生鼠标事件。 // 命中与回调仍走真实运行时,不用直接调用 count.increment 代替。 let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap()); assert_eq!(response.0, 200, "{}", response.2); // 持久 Console 读取同一个按钮状态,证明 HTTP 事件进入当前 VM 且恰好执行一次。 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" ); // 零倍率是结构/参数错误,应返回 400 且不污染 frame.fault 或使健康 VM 停止。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 600.0, height: 400.0, }), ..NamespaceState::default() })); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 父 Column 的 20 内边距和视口左边框 10 让内容原点不同于外框,暴露漏减 inset 的锚点错误。 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)) "#; // 正常发布一次基线,所有接下来手势仍指同一显式 Camera2D 对象。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); // 按最新权威帧查 CanvasViewport 具体出现,不能用主页面 mount=0 发局部相机输入。 let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 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() }; // 保留同一事件地址,平移/缩放行为只由输入 kind 和目标能力决定。 let path = "/v1/namespaces/app.main/events"; // 普通二维线性平移可无 point,仍必须有真实视口 Mount 和有限 delta。 let pan = camera("viewport-pan", [20.0, -10.0], None); // 首次 pan 应成功,旧正文重发则过期;源模型相机状态后面经 REPL 验证。 let response = client.request("POST", path, &pan); assert_eq!(response.0, 200, "{}", response.2); assert_eq!(client.request("POST", path, &pan).0, 409); // 局部缩放锚点先撤父偏移/边框再计算,不能直接用屏幕 80/60 作为内容相对坐标。 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"), ] { // 三个源属性分别比对,外观近似正确不能掩盖 x/y/zoom 中某个维度的变换错误。 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]] { // 重新生成当前版本以保证失败来自锚点边界,而不是误用旧版本得到 409。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 400., height: 300., }), ..NamespaceState::default() })); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 提前定义可替换 PanOnly 源控制器,初始仍使用默认 OrbitController 和透视相机作基线。 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) "##; // 通过实际语言构造 Scene3D/Viewport3D,不在测试宿主侧直接拼帧伪造相机能力。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); // 每次按最新帧找到 scene3d_viewport,来源能力和几何版本都交真实 HTTP 路由校验。 let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 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() }; // 同一事件路由支持不同源/原生控制器,不能为 PanOnly 建一条测试专用快捷调用。 let event_path = "/v1/namespaces/app.main/events"; // 视口内部锚点与倍率 2 应让默认透视相机距离减半,原正文重发仍必须 409。 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); // 经同一持久 Console 查询/修改真实相机,不从 Frame 近似反解相机模型值。 let value = |expression: &str| { // helper 仍跨 HTTP,确保候选保留的 globals 与事件真正执行域是同一份。 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"); // 默认 orbit 将横向增量解释为角度改变,检查 x 分量符合圆轨道而不是简单加 20。 let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None)); assert_eq!(response.0, 200, "{}", response.2); // 数字显示字符串转 f32 只用于三角近似容差,不放宽语言模型本身的类型契约。 let x = value("view.camera.x").parse::().unwrap(); assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001); value("view.camera = OrthographicCamera()"); // 换成正交相机后缩放应改 height,不能仍按透视距离规则改 z。 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()"); // 源控制器收到同一个 CameraGesture,行为应是精确平移 7,证明宿主没强行套默认 Orbit 规则。 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"); // 视口外锚点在进入控制器前返回 400;既不执行源更新,也不把 VM 标为 fault。 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\""); // 禁用控制器后原有 3D 外框仍存在,但手势能力失效,必须拒绝而不是自行补默认控制器。 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() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 400., height: 300., }), ..NamespaceState::default() })); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 倾斜 Panel3D 内放 CanvasViewport,使屏幕水平位移必须经射线/UV 非线性映射再解释成二维相机 pan。 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) "##; // 初次 PUT 产生真实三维面板投影和嵌套二维 Mount,下面不硬编码其内部 ID。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); // 专门选择 canvas_viewport 且 panel 非空的出现,避免误把外层三维相机当成目标。 let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 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() }; // 世界 UI 手势没有锚点不可可靠逆投影;第一条断言应 400 而不是执行近似屏幕平移。 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); // 查询真实模型数值,不只比视觉快照;返回 f32 供投影近似容差断言。 let value = |expression: &str| { // 每次表达式仍走持久 Console,若被误路由到新 VM 会因 canvas/view 不存在失败。 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() }; // 倾斜投影的有效 x 大于屏幕增量 10,外层 view.camera.x 应保持零,证明目标空间正确。 let pan = value("canvas.camera.x"); // 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。 assert!(pan > 10.); assert_eq!(value("view.camera.x"), 0.); // 再以同一屏幕锚点缩放,验证前一步平移和当前 UV 锚定公式可以连续组合。 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()); } #[test] /// 测试框架通过真实 HTTP 发送原始指针,验证局部坐标、按下/离开/取消状态,以及严格版本和结构拒绝不故障。 fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_versions() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 500., height: 400., }), ..Default::default() })); // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // Tracker 只记录事件坐标和 held,目标放在有偏移的 Column 下,暴露漏减出现原点的坐标错误。 let source = r#" class Tracker { x:Number y:Number held:Number function move(self:Tracker,e:Event):Unit {self.x=e.x self.y=e.y} function down(self:Tracker,e:Event):Unit {self.held=1} function up(self:Tracker,e:Event):Unit {self.held=0} } tracker=Tracker(0,0,0) target=RectBlock(200,100,0) a=target.on("pointermove",tracker.move) b=target.on("pointerdown",tracker.down) c=target.on("pointerup",tracker.up) d=target.on("pointerleave",tracker.up) e=target.on("pointercancel",tracker.up) page=Page() page.render(Column(20,0,target)) "#; // 安装原始 pointer 订阅并发布真实帧,不用测试直接赋值 tracker 字段模拟处理器。 let put = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(put.0, 200, "{}", put.2); // 除刻意重放 body 外,每次重新读版本,确保状态断言对应当前有效帧上的事件。 let input = |kind: &str| { // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; // 明确选择有原始指针能力的目标,绝对坐标为该出现原点加固定局部 35/27。 let mount = frame.mounts.iter().find(|m| m.pointer_events).unwrap(); crate::events::Input { window: 1, session: frame.session, layout_version: frame.layout_version, mount: mount.id, kind: kind.into(), point: Some([mount.x + 35., mount.y + 27.]), press_point: None, delta: None, tab_drop: None, screen_position: None, } }; // 保存最初正文作后续过期重放,不能重建最新版本后宣称在验证旧事件拒绝。 let body = serde_json::to_string(&input("pointermove")).unwrap(); // 第一遍合法移动应把局部坐标传给源 Tracker;它本身不一定触发视觉树变化。 let response = client.request("POST", "/v1/namespaces/app.main/events", &body); assert_eq!(response.0, 200, "{}", response.2); // Tracker 业务状态未被画面读取,不保证版本变化;显式改 target.width 产生真实新几何再重放旧正文。 assert_eq!( client .request("POST", "/v1/namespaces/app.main/repl", "target.width=201") .0, 200 ); assert_eq!( client .request("POST", "/v1/namespaces/app.main/events", &body) .0, 409 ); for (expr, expected) in [("tracker.x", "35"), ("tracker.y", "27")] { // 逐轴验证源处理器收到的是 Mount 局部 35/27,而非 Column 偏移后的页面绝对坐标。 let r = client.request("POST", "/v1/namespaces/app.main/repl", expr); assert_eq!(r.0, 200); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], expected ); } for (kind, expected) in [ ("pointerdown", "1"), ("pointerup", "0"), ("pointerdown", "1"), ("pointerleave", "0"), ("pointerdown", "1"), ("pointercancel", "0"), ] { // 每组都以真实事件改变 held,leave/cancel 必须与 up 一样结束按住状态,而非继续保留 1。 let response = client.request( "POST", "/v1/namespaces/app.main/events", &serde_json::to_string(&input(kind)).unwrap(), ); assert_eq!(response.0, 200, "{}", response.2); // 通过 Console 读同一对象最终状态,HTTP 200 本身不能证明处理器真的执行正确。 let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held"); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], expected ); } // 只删必填 point,保持当前合法 session/版本,使失败原因明确是结构错误 400 而非过期 409。 let mut invalid = input("pointermove"); invalid.point = None; assert_eq!( client .request( "POST", "/v1/namespaces/app.main/events", &serde_json::to_string(&invalid).unwrap() ) .0, 400 ); assert!(namespace.read().unwrap().document.frame.fault.is_none()); } #[test] /// 测试框架直接向原生队列送可信信封,验证动画新帧可追随,但 HTTP 旧帧和旧 VM 身份仍不能执行。 fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 500., height: 400., }), ..Default::default() })); // 装入实际共享 BuildRequest sender,来源信任来自 Rust 枚举构造,不通过伪造 HTTP 头获得。 let native = Arc::new(OnceLock::new()); // 复用真实 worker/路由及回执处理,只不安装 GUI 退出句柄和观察通知。 let api = start_server_observed( server.clone(), namespace.clone(), Arc::new(|| {}), Arc::new(Mutex::new(ActivityLog::default())), Arc::new(|| {}), None, Some(native.clone()), ) .unwrap(); // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // 模型保留 pointermove/down/up 三种订阅,后面视觉宽度变化只使版本更新、不替换原目标。 let source = r#" class Tracker { x:Number held:Number function move(self:Tracker,e:Event):Unit {self.x=e.x} function down(self:Tracker,e:Event):Unit {self.held=1} function up(self:Tracker,e:Event):Unit {self.held=0} } tracker=Tracker(0,0) target=RectBlock(200,100,0) a=target.on("pointermove",tracker.move) b=target.on("pointerdown",tracker.down) c=target.on("pointerup",tracker.up) page=Page() page.render(Column(20,0,target)) "#; assert_eq!( client .request("PUT", "/v1/namespaces/app.main/code", source) .0, 200 ); // 捕获一次真实帧,之后所有原生手势故意沿用这个旧版本,以覆盖 worker 中的第二次版本追随。 let old = { // 同锁读取完整捕获上下文,避免把新 layout_version 与旧几何坐标组合成假信封。 let state = namespace.read().unwrap(); // f 是已发布主帧,不是后台刷新草稿。 let f = &state.document.frame; // m 的身份在同一个 VM 中保持稳定,改变宽度不应让旧捕获手势换目标。 let m = f.mounts.iter().find(|m| m.pointer_events).unwrap(); crate::events::Input { window: 1, session: f.session, layout_version: f.layout_version, mount: m.id, kind: "pointermove".into(), point: Some([m.x + 37., m.y + 20.]), press_point: None, delta: None, tab_drop: None, screen_position: None, } }; // 模拟 relay 构造真正 Native 回执工作;send 本身是同步阻塞入队,下面 5 秒只限制入队后的回执等待。 // 安全拒绝可能映为 Ok,必须结合后面的模型值断言判断旧输入没有执行。 let send_native = |input: crate::events::Input| { // 只采样最新接受 epoch/name,不改输入自己捕获的 session/Mount,后者仍须由 worker 校验。 let state = namespace.read().unwrap(); // epoch 属于队列接受身份,和 input.session 代表的原 VM 代际分别保留。 let epoch = state.submission; // Clone 使发队列/等待时无需持 namespace 读锁,否则 worker 发布会被测试自己阻塞。 let name = state.name.clone(); drop(state); // 每条输入一个回执,最多等 5 秒,确认 worker 已处理后再读取 Tracker 状态。 let (done, complete) = sync_channel(1); native .get() .unwrap() .send(BuildRequest { request: RequestReply::Native(done), native: Some(input), kind: RequestKind::Event, source: String::new(), namespace: name, submission: epoch, }) .unwrap_or_else(|_| panic!("native worker stopped")); complete .recv_timeout(Duration::from_secs(5)) .unwrap() .unwrap(); }; // 每条原生事件前先做一次真实视觉变更模拟动画新帧,仍沿用 old 的布局版本验证可追随路径。 for (i, kind) in ["pointermove", "pointerdown", "pointerup"] .into_iter() .enumerate() { // 宽度每轮不同以确保发布新几何;只写相同值可能不更新版本,不能可靠覆盖追随逻辑。 assert_eq!( client .request( "POST", "/v1/namespaces/app.main/repl", &format!("target.width={}", 201 + i) ) .0, 200 ); // 只改 kind 不改捕获上下文,若 worker 没有在 BeforeInput 后再刷新版本就会拒绝这些合法原生动作。 let mut sample = old.clone(); sample.kind = kind.into(); send_native(sample); } for (code, value) in [("tracker.x", "37"), ("tracker.held", "0")] { // 先确认跨多帧原生输入实际执行并保持 down/up 顺序,不把单纯回执成功当作处理器成功。 let r = client.request("POST", "/v1/namespaces/app.main/repl", code); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], value ); } assert_eq!( client .request( "POST", "/v1/namespaces/app.main/events", &serde_json::to_string(&old).unwrap() ) .0, 409 ); assert_eq!( client .request("PUT", "/v1/namespaces/app.main/code", source) .0, 200 ); // 完整 PUT 之后即使数字 Mount 恰好相同也不能追随旧 session,确认 stale down 不会把新 held 变 1。 let mut stale = old; stale.kind = "pointerdown".into(); send_native(stale); // Native 回执可能把安全拒绝映为 Ok,最终必须用新 VM 模型值为零证明旧代事件未执行。 let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held"); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], "0" ); }