From ed1a9d2956ccceafa734703d7d8f8d4c9b1a49d3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:27:39 +0800 Subject: [PATCH] docs: explain worker publication and HTTP integration regressions --- src/runtime/api/guide.rs | 8 + src/runtime/api/tests.rs | 209 ++++++++++++++++++++- src/runtime/api/worker.rs | 169 +++++++++++++---- src/runtime/endor_host/manuscript_tests.rs | 55 +++++- 4 files changed, 393 insertions(+), 48 deletions(-) diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index c8e6f7f..b12dc57 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -1,12 +1,16 @@ //! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 //! //! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。 +//! api::handle 的 GET /agent 分支在 NamespaceState 读锁内调用 agent_guide,随后按 text/markdown 返回。 +//! 原始字符串中的 ```endor/JSON/HTTP 等代码块是给 API 使用者看的文档数据,不是 Rust 会执行的逻辑。 use super::*; /// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。 /// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入, /// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。 +/// 函数只读传入状态,不能由“指南显示某版本”推断新候选已成功发布;revision 仍以当前发布状态为准。 pub(super) fn agent_guide(namespace: &NamespaceState) -> String { + // 一次 format 生成完整响应字符串;原始字符串保留文档换行,具名插值在模板结束处统一提供。 format!( r##"# Tessera Agent API @@ -253,9 +257,13 @@ binding. Mouse routing, tab chrome/multiple windows and incremental layout remai in progress; process isolation is future work. Console retains objects only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox. "##, + // 路由指向当前唯一活 namespace,不由请求查询串或任意模板文件决定。 name = namespace.name, + // 只显示最近发布的 revision,不把正在排队的 submission 当作已经成功的新版本。 revision = namespace.revision, + // 与 api::start 的实际监听常量一致;不扫描其他端口或猜测外部网络地址。 address = API_ADDRESS, + // include_str 在应用构建时嵌入例子,处理 GET 时不读磁盘,也不在 VM 运行这个例子。 example = include_str!("../../../examples/page.endor"), ) } diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index f00e04c..e5ec622 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -1,6 +1,9 @@ //! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 //! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。 //! +//! Rust 测试框架调用下方 #[test];大多数测试通过 TestServer::request 访问真实 tiny_http 路由和串行 VM worker。 +//! 少量测试直接安装 Worker 的活会话以精确触发发布身份边界,仍调用真实 ReplSession 而非模拟对象堆。 +//! r# 字符串中的 Endor 是测试夹具,行号/诊断也可能是契约的一部分;注释只加在 Rust 测试逻辑外层。 use super::*; use std::{ io::Write, @@ -13,6 +16,7 @@ use std::{ /// 模拟 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", @@ -25,6 +29,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { 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 @@ -32,16 +37,19 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { .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, @@ -55,6 +63,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { 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, @@ -74,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { ); 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)); @@ -85,8 +95,11 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { /// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。 struct TestServer { + // 拥有实际 Server 以便 Drop 调用 unblock,中断 incoming_requests 等待。 server: Arc, + // 每例实际绑定端口,从 server_addr 读取而非硬编码应用默认地址。 address: SocketAddr, + // Option 允许 Drop 取出句柄只 join 一次,不从借用的 self 中移动裸 JoinHandle。 api: Option>, } @@ -94,6 +107,7 @@ impl TestServer { /// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。 /// 返回状态码、原始头和正文;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))) @@ -102,9 +116,12 @@ impl TestServer { .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()) } @@ -115,6 +132,7 @@ impl Drop for TestServer { fn drop(&mut self) { self.server.unblock(); if let Some(api) = self.api.take() { + // 已解除 accept 等待,join 确保本例线程结束;线程异常由具体测试断言报告,不在 Drop 二次 panic。 let _ = api.join(); } } @@ -124,14 +142,19 @@ impl Drop for TestServer { /// 通过 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) } @@ -144,10 +167,14 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { 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, @@ -168,9 +195,12 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { }; // 保留同一事件正文用于重放测试,不能重新读取新 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!( @@ -190,14 +220,19 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { /// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。 /// 之后多次 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) } } @@ -205,10 +240,13 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() { 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() @@ -230,22 +268,30 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() { /// 依次创建未挂载对象、读取共享值、挂载、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"]); @@ -253,6 +299,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { client.request("POST", repl, "ui.page().render(title)").0, 200 ); + // render 后 GET 应识别为 transcript;reset 只清 VM 绑定,不应抹去此已发布正文。 let snapshot = client.request("GET", code, ""); assert!( snapshot @@ -280,34 +327,47 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { /// 经真实 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); + // 未定义名字触发编译诊断,422 必须保留第 1 行来源且不覆盖前面成功源。 let (status, _, body) = client.request("PUT", route, "function main(): View { return missing }"); assert_eq!(status, 422, "{body}"); @@ -316,6 +376,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { 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); @@ -337,6 +398,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { /// 直接推进 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", @@ -344,16 +406,19 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { 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); @@ -369,6 +434,7 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { "9" ); { + // 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。 let mut current = namespace.write().unwrap(); current.submission = 3; worker.preserve_live_owner(¤t); @@ -387,20 +453,29 @@ fn http_delete_remains_available_and_invalidates_queued_put() { 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, @@ -412,19 +487,28 @@ fn http_delete_remains_available_and_invalidates_queued_put() { 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)); } @@ -453,6 +537,7 @@ fn extracts_namespace_only_from_code_routes() { #[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()")); @@ -465,6 +550,7 @@ fn guide_contains_only_code_crud_routes() { /// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。 /// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。 fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() { + // ready 定时器会把共享 count 从 0 递增,前台模式不应提前消费它,因此提供明确可观察顺序。 let source = r#" class Action { count: Object @@ -476,20 +562,25 @@ 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() @@ -500,8 +591,10 @@ page.render(TextBlock("input priority")) .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; } @@ -509,6 +602,7 @@ page.render(TextBlock("input priority")) .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"); } @@ -517,7 +611,9 @@ page.render(TextBlock("input priority")) /// 在负世界坐标放置按钮,通过 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, @@ -525,12 +621,15 @@ fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() { }), ..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") @@ -541,9 +640,12 @@ 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, @@ -559,8 +661,11 @@ page.render(button) }) .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!( @@ -568,6 +673,7 @@ page.render(button) 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, @@ -575,6 +681,8 @@ page.render(button) ); 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!( @@ -582,9 +690,13 @@ page.render(button) 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 { @@ -600,14 +712,17 @@ page.render(button) tab_drop: None, } }; + // 成对按下/释放同点走 source_hit 与真实 click 回调,不用直接调用 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, @@ -621,7 +736,9 @@ page.render(button) /// 给局部 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, @@ -629,12 +746,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin }), ..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 @@ -645,10 +765,14 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin 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, @@ -669,11 +793,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin }) .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, @@ -685,6 +813,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin ("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!( @@ -694,6 +823,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin } // 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。 for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] { + // 重新生成当前版本以保证失败来自锚点边界,而不是误用旧版本得到 409。 let response = client.request( "POST", path, @@ -708,7 +838,9 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin /// 通过真实 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., @@ -716,12 +848,15 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { }), ..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) } @@ -730,10 +865,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { 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, @@ -754,12 +893,17 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { }) .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"] @@ -768,11 +912,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { .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, @@ -781,9 +928,11 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { 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, @@ -792,6 +941,7 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { 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()); @@ -801,7 +951,9 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { /// 在旋转的 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., @@ -809,12 +961,15 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { }), ..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) @@ -825,10 +980,14 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { 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, @@ -849,6 +1008,7 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { }) .unwrap() }; + // 世界 UI 手势没有锚点不可可靠逆投影;第一条断言应 400 而不是执行近似屏幕平移。 let path = "/v1/namespaces/app.main/events"; assert_eq!( client @@ -856,13 +1016,16 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { .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"] @@ -871,10 +1034,12 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { .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, @@ -887,8 +1052,11 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { } #[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., @@ -896,12 +1064,15 @@ fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_ }), ..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 @@ -921,11 +1092,16 @@ 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, @@ -940,11 +1116,12 @@ page.render(Column(20,0,target)) 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 state alone need not publish a new picture. Expire the frame - // through a real visual mutation before asserting strict stale rejection. + // Tracker 业务状态未被画面读取,不保证版本变化;显式改 target.width 产生真实新几何再重放旧正文。 assert_eq!( client .request("POST", "/v1/namespaces/app.main/repl", "target.width=201") @@ -958,6 +1135,7 @@ page.render(Column(20,0,target)) 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!( @@ -973,18 +1151,21 @@ page.render(Column(20,0,target)) ("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!( @@ -1001,8 +1182,11 @@ page.render(Column(20,0,target)) } #[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., @@ -1010,7 +1194,9 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { }), ..Default::default() })); + // 装入实际共享 BuildRequest sender,来源信任来自 Rust 枚举构造,不通过伪造 HTTP 头获得。 let native = Arc::new(OnceLock::new()); + // 复用真实 worker/路由及回执处理,只不安装 GUI 退出句柄和观察通知。 let api = start_server_observed( server.clone(), namespace.clone(), @@ -1021,11 +1207,13 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { 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 @@ -1048,9 +1236,13 @@ page.render(Column(20,0,target)) .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, @@ -1065,11 +1257,16 @@ page.render(Column(20,0,target)) screen_position: None, } }; + // 模拟 relay 构造真正 Native 回执工作,等待有界确认;409 表示被安全拒绝/消费,也可得到 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() @@ -1088,12 +1285,12 @@ page.render(Column(20,0,target)) .unwrap() .unwrap(); }; - // Every native gesture deliberately carries the old picture version; each - // intervening visual mutation simulates an animation publishing a frame. + // 每条原生事件前先做一次真实视觉变更模拟动画新帧,仍沿用 old 的布局版本验证可追随路径。 for (i, kind) in ["pointermove", "pointerdown", "pointerup"] .into_iter() .enumerate() { + // 宽度每轮不同以确保发布新几何;只写相同值可能不更新版本,不能可靠覆盖追随逻辑。 assert_eq!( client .request( @@ -1104,11 +1301,13 @@ page.render(Column(20,0,target)) .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"], @@ -1131,9 +1330,11 @@ page.render(Column(20,0,target)) .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"], diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index 3260ea1..de404b0 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -2,14 +2,18 @@ //! 输入、视口刷新与候选布局可以跨 worker 轮次挂起;中途只公布完整文档,不能让新输入进入未结束的只读调用。 //! 事件身份同时校验窗口、session、layout_version 和 Mount 能力。失败、替换与关闭分别处理,避免旧回调或旧命令泄漏到新 VM。 //! -//! Serial VM ownership, incremental requests, publication and shutdown. +//! api::start_server_observed 的唯一执行线程创建 Worker,每次循环调用 begin_request/resume_request 或 refresh_viewport。 +//! HTTP/原生请求仍由外层持有,PendingRequest 只记录继续哪一步;本文件不重新读取请求流或回放已执行输入。 +//! NamespaceState 是对外权威值,repl 是实际 VM 所有者;submission 是接受顺序,runtime_session 才表示画面仍属哪个 VM。 use super::*; +// 响应已编码成内存字节,外层 RequestReply 可发 HTTP 或转换成原生回执,不需要读取 VM 返回值。 type HttpResponse = Response>>; // 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。 type InputOutcome = std::thread::Result)>>; +// 候选成功必须成套交回持久 VM、完整文档及命令;不能只返回快照后丢掉事件需要的真实运行时。 type CandidateOutcome = std::thread::Result< Result<( endor_host::ReplSession, @@ -22,8 +26,11 @@ type CandidateOutcome = std::thread::Result< pub(super) struct PendingInput { // 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。 epoch: u64, + // 实际运行时代际,和 epoch 一起核验;接受新 PUT 不代表已经切换到新 VM。 id: u64, + // 决定响应 execution 和错误停域规则,恢复展示时不再重新猜原始输入类别。 event: bool, + // 已经执行得到的值/统计,后续只补 document,不重复 callback 或 execute 取相同结果。 result: endor_host::ReplResult, } @@ -31,20 +38,25 @@ pub(super) struct PendingInput { pub(super) enum PendingRequest { // 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。 BeforeInput, + // 输入业务部分已执行,保留活 repl 并只恢复该输入的视觉刷新。 Input(Box), + // 新 PUT 的隔离候选还未发布,活 repl 仍保留旧程序,不能提前替换。 Candidate(Box), } /// 一次 worker 推进的结果:继续保留请求或交付最终响应。 pub(super) enum RequestProgress { + // 外层将原 job 和继续状态一起保存,下一量子进入 resume_request。 Pending(PendingRequest), + // 最终响应值,外层消费原 RequestReply 一次性回应,不再继续这个请求。 Complete(HttpResponse), } -/// 只提取仍属于当前 session 的窗口尺寸,避免旧原生窗口给新候选提供错误约束。 +/// 空闲刷新/输入/恢复路径在发布状态读锁内调用,筛出当前运行时合法尺寸,不执行 VM 或修改窗口表。 fn current_viewports( current: &NamespaceState, ) -> std::collections::BTreeMap { + // 主壳可跨 VM 使用;副窗必须仍在当前 document 且属于 runtime_session,旧数字 ID 本身不算归属。 let mut viewports: std::collections::BTreeMap<_, _> = current .window_viewports .iter() @@ -69,12 +81,15 @@ fn current_viewports( #[derive(Default)] /// 唯一活跃 VM 的串行拥有者,另行管理候选和下一 session 身份。 pub(super) struct Worker { + // 元组第一项是当前接受提交 epoch,第二项是真实 VM;新 PUT 接受时旧 VM 仍可能保持权威。 pub(super) repl: Option<(u64, endor_host::ReplSession)>, + // 单调 session 分配高水位,即使候选失败也不复用已经分配的代际身份。 pub(super) next_session: u64, } impl Worker { - /// 撤销活跃运行时并有界清理窗口、Mount 和任务;故障域不再执行新的业务回调。 + /// api 线程循环收到退出信号时调用,移出活运行时并生成纯文本 ShutdownOutcome,供原生退出报告。 pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome { + // take 先撤 worker 的活域拥有关系,没有域时仍返回明确成功摘要而非伪造清理数量。 let Some((_, mut runtime)) = self.repl.take() else { return ShutdownOutcome { summary: "Tessera shutdown: no live runtime".into(), @@ -83,6 +98,7 @@ impl Worker { }; // 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。 let callbacks = !runtime.is_faulted(); + // 普通清理错误与 Rust panic 都转换成结果,不能让执行线程提前消失而使 GUI 永远等不到完成通知。 let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); match result { Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome { @@ -92,10 +108,12 @@ impl Worker { error: None, }, result => { + // 内部 panic 没有可安全继续执行的源错误对象,单独说明;正常 Err 则保留错误链。 let error = match result { Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"), _ => "shutdown cleanup failed after an internal panic".into(), }; + // 按 Unicode 字符限长,不切坏 UTF-8,也不把无限错误链塞入退出消息。 let error: String = error.chars().take(4096).collect(); ShutdownOutcome { summary: error.clone(), @@ -105,7 +123,7 @@ impl Worker { } } - /// 处理当前无法继续正常交互的窗口关闭,撤销原生资源与相应运行时记录。 + /// begin_request 的故障/失活副窗 window-close 分支调用,做纯宿主退役,不运行任何源关闭/生命周期回调。 pub(super) fn close_inactive_window( &mut self, input: &crate::events::Input, @@ -120,11 +138,14 @@ impl Worker { "close the primary shell with its OS window control", ); } + // 只有 epoch/session 都匹配才清该 VM 内部资源;相同窗口 ID 不授权操作另一个会话。 let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| { *owner == epoch && runtime.id == input.session && input.session != 0 }); + // 退役内部失败时整个域被丢弃,后面需同时撤其他帧的会话/命令资格。 let mut discarded = false; if owns_runtime { + // 接住退役 panic,保证后续仍能从共享文档删除原生副窗投影,而不保留半清理 VM。 let result = catch_unwind(AssertUnwindSafe(|| { self.repl .as_mut() @@ -134,17 +155,19 @@ impl Worker { })); // 退役失败不能继续借用半清理 VM,放弃整个活域并在下方清其他帧的会话与命令资格。 if !matches!(result, Ok(Ok(()))) { - // Never keep a half-cleaned VM live or borrow another generation. + // 不借用其他代际补清理;放弃整个 repl 后下方只操作最近发布的纯数据文档。 self.repl = None; discarded = true; } } + // VM 清理在锁外执行完才申请发布写锁,重新验证期间是否有 PUT/DELETE 接受。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock poisoned"); }; if current.submission != epoch { return superseded(epoch); } + // 只查副窗投影,原窗口已消失就返回冲突,不为关闭旧事件重建一个资源。 let Some(view) = current.document.windows.get(&input.window) else { return error_response(StatusCode(409), "window is no longer active"); }; @@ -155,9 +178,11 @@ impl Worker { "window belongs to another runtime generation", ); } + // 在完整已发布文档副本上删目标,保留其他窗口画面;commit 再验证整份跨线程值。 let mut document = current.document.clone(); document.windows.remove(&input.window); if discarded { + // 保留静态画面但所有交互身份失效,不能让旧按钮继续指向已丢弃的托管堆。 document.frame.session = 0; document.frame.focus = None; for view in document.windows.values_mut() { @@ -181,7 +206,7 @@ impl Worker { ) } - /// 在没有前台输入的 worker 轮次推进尺寸和任务引起的展示刷新。 + /// api 的空闲 worker 轮调用,允许推进尺寸和任务回调;有前台输入等待时使用 before_input=true 的变体。 pub(super) fn refresh_viewport( &mut self, namespace: &Arc>, @@ -190,7 +215,7 @@ impl Worker { self.refresh_viewport_mode(namespace, notify, false); } - /// 在输入之间推进视口/任务刷新;提交序号或 session 已变时拒绝旧结果发布。 + /// 空闲循环与 begin_request 共用推进器,锁外执行 VM、写锁内核对 epoch/session 后再发布,防止晚到覆盖。 pub(super) fn refresh_viewport_mode( &mut self, namespace: &Arc>, @@ -200,11 +225,13 @@ impl Worker { // 在读锁内取得一个一致的提交身份和尺寸快照;耗时 VM 工作不占用共享状态写锁。 let (epoch, viewports) = match namespace.read() { Ok(current) => { + // 同一读锁下复制身份和合法尺寸,避免使用来自不同发布世代的两个快照。 let viewports = current_viewports(¤t); (current.submission, viewports) } Err(_) => return, }; + // 无持久运行时就没有任务/尺寸可推进;GET 的旧静态文档本身不创造 VM。 let Some((owner, runtime)) = &mut self.repl else { return; }; @@ -215,28 +242,31 @@ impl Worker { } return; } + // 保存执行前真实 VM 身份,发布时重新检查,不能只核对新的 submission。 let id = runtime.id; + // 耗时刷新不持 namespace 锁,HTTP 可接受新请求;普通错误/panic 留到写锁阶段区分处理。 let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { if runtime.is_faulted() { + // 故障域 poll_tasks 仅取消/回收旧任务,不再启动新的用户回调。 runtime.poll_tasks()?; return Ok((None, Vec::new())); } + // 先推进已有视觉工作/尺寸,再考虑新任务,不能在只读续体里插入业务写入。 let resized = runtime.resize_windows_incremental(&viewports, false)?; if runtime.has_pending_refresh() { return Ok((None, Vec::new())); } - // An accepted foreground input waits only for the existing - // refresh; do not keep placing new callbacks ahead of it. // 已有前台请求只等当前刷新,不不断把新任务回调插到它前面,避免忙后台工作饿死用户输入。 let completed = if before_input { + // 前台输入的优先等待边界,不再让不断就绪的后台任务排在用户输入前。 None } else { + // 真正空闲才开启一个任务推进,任务引起的展示仍可挂起等待后续量子。 runtime.poll_tasks_incremental(&viewports)? }; // 任务后续刷新未收敛时可以发布较早已完整的 resize 文档,但暂扣任务产生的命令,防止半完成副作用外泄。 if runtime.has_pending_refresh() { - // A complete earlier resize may publish; task effects and their - // native commands wait until the task's refresh has settled. + // resized 已完整可以返回,任务新产生窗口命令仍扣留至其完整刷新结束。 return Ok((resized, Vec::new())); } Ok((completed.or(resized), runtime.take_window_commands()?)) @@ -250,6 +280,7 @@ impl Worker { if current.submission != epoch || current.runtime_session != id { return; } + // success 可仅有命令而无新帧;错误发布故障条,panic 还要丢弃 VM 与命令世代。 let (document, commands) = match result { Ok(Ok((document, commands))) => { if let Some(document) = document { @@ -266,6 +297,7 @@ impl Worker { } } failure => { + // 故障画面基于上次完整发布文档,不把此次失败的半成品几何泄漏出去。 let mut document = current.document.clone(); document.frame.fault = Some(match failure { Ok(Err(error)) => format!("Runtime background update failed: {error:#}") @@ -286,6 +318,7 @@ impl Worker { (document, None) } }; + // 只有提交成功才发布同世代原生命令和通知 GUI,错误/过期结果不能算一次有效刷新。 let changed = matches!(current.commit(epoch, document), Ok(Some(_))); if changed && let Some(commands) = commands { current.publish_window_commands(id, commands); @@ -296,7 +329,7 @@ impl Worker { } } - /// 检查请求种类和归属,必要时先等已有刷新结束,再建立输入或候选任务。 + /// api 线程循环收到 job 或恢复 BeforeInput 时调用;业务未执行前可等待旧刷新,执行后切换 PendingInput。 pub(super) fn begin_request( &mut self, job: &BuildRequest, @@ -305,15 +338,16 @@ impl Worker { ) -> RequestProgress { // 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。 if job.kind == RequestKind::Code { + // PUT 创建隔离候选,不向当前 VM 追加语句;失败可保留旧程序的实际对象图。 return self.begin_program_request(job, namespace, notify); } if job.kind == RequestKind::Expression { + // evaluate 不访问活 VM/显示文档,无需等待当前只读刷新完成。 return RequestProgress::Complete(build(job, namespace, notify)); } - // Advance one existing refresh quantum, then park the HTTP request. - // No input statement executes inside the read-only scope; shutdown and - // newer viewport targets can be observed at every worker turn. + // 先只推进已有刷新一片,关闭和最新尺寸每个 worker 控制边界仍可见;未空闲不运行新语句。 self.refresh_viewport_mode(namespace, notify, true); + // 推进可能跨发布边界,重新读取当前接受序号,不沿用 job 作为最新状态。 let epoch = match namespace.read() { Ok(current) => current.submission, Err(_) => { @@ -324,10 +358,12 @@ impl Worker { } }; if epoch != job.submission { + // 尚未执行的旧输入被新提交撤销,不能借“仍有旧 VM”在新接受顺序后插入旧队列动作。 return RequestProgress::Complete(superseded(job.submission)); } // 重置丢弃内存会话但保留显示文档,清帧 session/focus 使旧画面不再接受旧对象事件。 if job.kind == RequestKind::ResetRepl { + // 丢真正 VM 的内存状态,画面只作为不可交互静态值保留,不声称用户对象仍可响应。 self.repl = None; if let Ok(mut current) = namespace.write() { current.document.frame.session = 0; @@ -350,13 +386,17 @@ impl Worker { .as_ref() .is_some_and(|(_, runtime)| runtime.has_pending_refresh()) { + // BeforeInput 表示尚无业务副作用,恢复可重新走 begin_request;不能把已执行输入也放此状态。 return RequestProgress::Pending(PendingRequest::BeforeInput); } // 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。 let event = if job.kind == RequestKind::Event { + // 可信原生来源取自进程内 job.native;HTTP 正文只能被严格反序列化成普通事件。 let mut input: crate::events::Input = if let Some(input) = &job.native { + // 原生 Input 仍需下面全部字段/能力验证,可信来源只影响连续输入能否追随新布局版本。 input.clone() } else { + // deny_unknown_fields 和类型检查由 Input 的 serde 声明执行,不按任意 JSON 字段猜动作。 match serde_json::from_str(&job.source) { Ok(input) => input, Err(error) => { @@ -392,14 +432,17 @@ impl Worker { | "window-close" ) { + // 开发工具名字由后续工具白名单再查;其余只接受明确列出的原生输入类别。 return RequestProgress::Complete(error_response( StatusCode(400), "unsupported event kind", )); } if let Err(message) = input.validate() { + // 结构错属于 400,在碰任何活 VM 对象之前回应,不能当作过期帧冲突或源运行错误。 return RequestProgress::Complete(error_response(StatusCode(400), message)); } + // 以下窗口、session、版本与几何能力都从同一个权威发布状态读锁中核对。 let current = match namespace.read() { Ok(current) => current, Err(_) => { @@ -409,8 +452,7 @@ impl Worker { )); } }; - // Rebase trusted captured native gestures only after BeforeInput - // has drained the prior refresh. External HTTP remains strict. + // 原生 relay 排队期间可能又完成了 BeforeInput 刷新;在此再追随一次同目标新版本,HTTP 保持严格。 if job.native.is_some() && !crate::events::refresh_continuous_input(&mut input, ¤t) { @@ -419,9 +461,11 @@ impl Worker { "native target was replaced or detached", )); } + // 必须选输入声明的窗口帧,不能因为副窗缺失就回退到主窗口同数值 Mount。 let frame = if input.window == crate::window::MAIN_WINDOW { ¤t.document.frame } else { + // 副窗只来自 current.document.windows,GUI 残留 OS 窗口不自动代表活域资源。 let Some(document) = current.document.windows.get(&input.window) else { return RequestProgress::Complete(error_response( StatusCode(409), @@ -435,9 +479,11 @@ impl Worker { && frame.window == input.window && frame.session == input.session { + // 有效资源身份但 VM 已停的关闭可进入纯宿主退役,其他输入仍必须拒绝。 let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| { *owner == epoch && runtime.id == input.session && input.session != 0 }); + // 画面故障、无对应活 VM 或 VM 内已 faulted 都说明不能调用可覆盖的源 Window.close。 let stopped = frame.fault.is_some() || !owns || self @@ -445,6 +491,7 @@ impl Worker { .as_ref() .is_some_and(|(_, runtime)| runtime.is_faulted()); if stopped { + // close_inactive_window 需要发布写锁,调用前必须释放这里持有的读锁。 drop(current); return RequestProgress::Complete( self.close_inactive_window(&input, epoch, namespace, notify), @@ -453,6 +500,7 @@ impl Worker { } // 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。 if let Some(target) = &input.tab_drop { + // 拖放目标同样要找到原窗口权威帧,不能只验证源标签仍存在就信任目标下标。 let target_frame = if target.window == crate::window::MAIN_WINDOW { Some(¤t.document.frame) } else { @@ -463,6 +511,7 @@ impl Worker { .map(|document| &document.frame) }; if target_frame.is_none_or(|frame| { + // 列表插入缝允许等于 tabs.len,超过长度、换代、换几何或故障都拒绝。 frame.window != target.window || frame.session != input.session || frame.layout_version != target.layout_version @@ -473,6 +522,7 @@ impl Worker { .as_ref() .is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input)) { + // 错误是本次目标已过期,不让源 moveToWindow 方法运行到一半才发现无法重排。 return RequestProgress::Complete(error_response( StatusCode(409), "drop targets an obsolete or inactive frame", @@ -488,6 +538,7 @@ impl Worker { || input.kind.starts_with("developer-open:") || matches!(input.kind.as_str(), "page-pan" | "page-zoom") { + // 全窗关闭、工具打开与整页相机不指向某个 Mount,必须携带零目标,防止身份多义。 input.mount != 0 } else if matches!( input.kind.as_str(), @@ -496,26 +547,32 @@ impl Worker { // 标签操作的 mount 字段承载 Tab ID,而普通输入承载 Mount ID;两套身份不能混着查询。 !frame.tabs.iter().any(|tab| tab.id == input.mount) } else { + // 普通 Mount 输入查出现表再按 kind 校验对应能力,不能只验证存在同号几何。 !frame.mounts.iter().any(|mount| { mount.id == input.mount && if input.kind.starts_with("viewport-") { + // 二维局部相机仅允许 CanvasViewport,普通可点击矩形不能变成相机目标。 mount.canvas_viewport } else if input.kind.starts_with("scene-") { // 3D 手势还要求显式控制器能力,scroll 要有滚动投影,点击/焦点则要求 interactive。 mount.scene3d_viewport && mount.camera_controls } else if input.kind == "scroll" { + // 滚动投影存在才有滚动语义,不凭 Block 名义类型或外框可点击推测。 mount.scroll.is_some() } else { + // 点击/焦点等至少要求交互资格;具体源 hitTest/指针订阅仍在 bridge 中再查。 mount.interactive } }) } || self.repl.as_ref().is_none_or(|(context, runtime)| { + // 发布帧与实际活域必须匹配;静态保留画面不能用作对不存在 VM 的调用凭据。 *context != epoch || runtime.id != input.session || (input.kind != "window-close" && !runtime.accepts_window(input.window)) }) { + // 同一统一门在任何源回调之前拒绝,不能先执行部分动作再补查会话身份。 return RequestProgress::Complete(error_response( StatusCode(409), "event targets an obsolete or inactive frame", @@ -535,6 +592,7 @@ impl Worker { && !frame.mounts.iter().any(|mount| { mount.id == input.mount && input.point.is_some_and(|point| { + // 先检查出现外框,再用面板/视图逆映射检测真实内容 clip,边框不作缩放锚点。 let point = crate::geometry::Point::new(point[0], point[1]); mount.contains(point) && mount.viewport_clip.is_some_and(|clip| { @@ -548,6 +606,7 @@ impl Worker { }) }) { + // 锚点必须在实际内容区域内,外框/面板反投影验证失败就先拒绝,不运行相机控制器。 return RequestProgress::Complete(error_response( StatusCode(400), "zoom anchor is outside content viewport", @@ -561,6 +620,7 @@ impl Worker { .iter() .any(|mount| mount.id == input.mount && mount.panel.is_some()) { + // 缺锚点的线性二维增量尚可解释,世界面板的非线性映射则无法形成可靠局部位移。 return RequestProgress::Complete(error_response( StatusCode(400), "world-space UI camera input requires a pointer", @@ -568,6 +628,7 @@ impl Worker { } Some(input) } else { + // Console 不是事件,不制造一个空 Input;下面按 source 执行同一持久会话的交互语句。 None }; if self @@ -585,9 +646,11 @@ impl Worker { )); } }; + // 空 Console 初建只沿用主壳尺寸,不从静态旧帧复制可变业务对象或副窗资源。 let viewport = namespace.read().ok().and_then(|current| current.viewport); match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) { Ok(session) => { + // 仅共享状态仍是当前 epoch 才记录新命令世代;真正画面仍等待源程序打开页面。 if let Ok(mut current) = namespace.write() && current.submission == epoch { @@ -603,6 +666,7 @@ impl Worker { } } } + // 业务执行前复制合法视口集合,之后整个 VM 调用不持 namespace 锁。 let viewports = match namespace.read() { Ok(current) => current_viewports(¤t), Err(_) => { @@ -612,9 +676,13 @@ impl Worker { )); } }; + // 惰性创建已保证 repl 存在,借用这个实际会话而非重放历史或建立每请求临时 VM。 let session = &mut self.repl.as_mut().expect("initialized REPL").1; + // 保存实际会话号,执行后可能收到新 PUT,finish_input 会再次核验归属。 let id = session.id; + // 统一收集可诊断错误与内部 panic,后者必须丢弃域,不能继续借损坏对象图交互。 let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + // 两种输入只执行一次业务,挂起时 PendingInput 将保存这个返回值,只继续视觉阶段。 let result = if let Some(event) = &event { session.dispatch_incremental(event, &viewports)? } else { @@ -628,6 +696,7 @@ impl Worker { }; Ok((result, commands)) })); + // 是否需对可见帧加故障条取决于 VM 的实际执行状态,普通编译失败不等于整个域已损坏。 let faulted = session.is_faulted(); if session.has_pending_refresh() { if let Ok(Ok((result, _))) = result { @@ -639,7 +708,7 @@ impl Worker { }))); } // 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。 - // A failed start must never leave a request waiting on a broken VM. + // 不给无法恢复的执行错误留下“等待下一片可能成功”的请求,保留完整旧画面另作响应。 self.repl = None; } RequestProgress::Complete(self.finish_input( @@ -654,7 +723,7 @@ impl Worker { )) } - /// 恢复已挂起的请求,保持原 HTTP 响应和运行时身份直到最终完成。 + /// api 外层循环保存原 job 后每量子调用,按继续状态分流,只有 BeforeInput 能重新进入业务入口。 pub(super) fn resume_request( &mut self, job: &BuildRequest, @@ -663,6 +732,7 @@ impl Worker { notify: &Notify, ) -> RequestProgress { match pending { + // 业务尚未执行,可重新核验提交和等待条件;Input/Candidate 都只继续原执行域。 PendingRequest::BeforeInput => self.begin_request(job, namespace, notify), PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify), PendingRequest::Candidate(runtime) => { @@ -671,7 +741,7 @@ impl Worker { } } - /// 继续同一 Console/事件输入的展示刷新,不再次执行其业务回调。 + /// resume_request 的 Input 分支调用,核验原会话并继续原视觉工作,保留先前返回值/统计不重执行语句。 fn resume_input( &mut self, job: &BuildRequest, @@ -679,6 +749,7 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 可继续条件和最新合法尺寸在同一读锁中取得,执行时不长期持有发布锁。 let viewports = match namespace.read() { // 正在执行输入只要仍属当前提交或仍拥有权威 VM 就可收敛;两项皆失效才彻底废弃。 Ok(current) @@ -687,6 +758,7 @@ impl Worker { current_viewports(¤t) } Ok(_) => { + // 提交和权威 VM 都已撤销,不能把旧输入结果重新提交到另一个活动程序上。 self.repl = None; return RequestProgress::Complete(superseded(job.submission)); } @@ -698,6 +770,7 @@ impl Worker { )); } }; + // PendingInput 不自己强持另一个运行时,必须与 Worker 当前拥有的真实 VM 一致。 let Some((epoch, session)) = &mut self.repl else { return RequestProgress::Complete(error_response( StatusCode(409), @@ -707,6 +780,7 @@ impl Worker { if *epoch != input.epoch || session.id != input.id { return RequestProgress::Complete(superseded(job.submission)); } + // 只推进 resize/刷新,catch_unwind 保证源视觉方法 panic 后可撤销整个损坏域。 let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { // 这里只恢复 pending 展示/尺寸工作,不再调用 execute/dispatch,所以前一个量子的源副作用不会重放。 let document = session.resize_windows_incremental(&viewports, false)?; @@ -715,10 +789,13 @@ impl Worker { } Ok(Some((document, session.take_window_commands()?))) })); + // 恢复失败是否停域由实际 runtime 状态决定,交 finish_input 统一决定画面故障报告。 let faulted = session.is_faulted(); + // Pending 原样保留输入元数据,Complete 才把新文档补入第一次业务执行的结果。 let result = match resumed { Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)), Ok(Ok(Some((document, commands)))) => { + // 不覆盖 value/input/bindings,避免一次挂起输入变成第二次 Console 记录。 input.result.document = document; Ok(Ok((input.result, commands))) } @@ -738,7 +815,7 @@ impl Worker { } #[allow(clippy::too_many_arguments)] - /// 校验输入仍属于当前域,再一次性发布结果、元数据和原生窗口命令。 + /// begin_request/resume_input 终结输入时调用,写锁内核验接受身份,提交完整文档与同 VM 命令后回应。 fn finish_input( &mut self, job: &BuildRequest, @@ -750,30 +827,33 @@ impl Worker { faulted: bool, result: InputOutcome, ) -> HttpResponse { + // 锁覆盖提交检查/文档/命令世代更新,HTTP 新 PUT 不能插在验证与提交之间制造竞态。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; // 接受新 PUT 会使排队旧输入失效,但已执行的活域输入需先收敛,才能在候选失败时留下内部一致的旧程序。 let epoch = if current.submission != epoch { if current.runtime_session != id { + // 已失去实际画面归属才彻底丢旧 VM,避免旧输入在另一代程序上产生回执或发布。 drop(current); self.repl = None; return superseded(job.submission); } - // Accepted PUTs invalidate queued inputs, not a mutation already - // executing on this owner. Settle it before building the candidate - // so a failed replacement can preserve a consistent live heap. + // 新 PUT 撤销未执行旧队列项,但旧 VM 已开始的变更要先收敛,候选失败才有一致的旧堆可保留。 if let Some((owner, runtime)) = &mut self.repl && runtime.id == id { + // 更新活 VM 的接受 epoch,而不改变其 session;不是把它冒充为新候选或复制对象图。 *owner = current.submission; } current.submission } else { + // 接受序号没变,直接沿用原输入 epoch,不制造额外 revision 或 session。 epoch }; match result { Ok(Ok((result, commands))) => { + // 有标题/焦点等命令即使无新文档也需通知 GUI,和 rendered 的含义分别记录。 let projected = !commands.is_empty(); // rendered 只表示此次交付带新文档,成功执行但页面未变仍可返回 value/session;不因此虚增 revision。 let rendered = result.document.is_some(); @@ -785,6 +865,7 @@ impl Worker { } } current.publish_window_commands(id, commands); + // 在释放锁前读取这次确定的 revision,响应不再为字符串化借用共享状态。 let revision = current.revision; drop(current); if rendered || projected { @@ -797,10 +878,8 @@ impl Worker { } Ok(Err(error)) => { if event || faulted { + // 事件/渲染回调故障会影响整个共享 VM;单纯 Console 编译错误可只返回 422 而不覆盖画面。 let mut document = current.document.clone(); - // Preserve the first failure of this VM; subsequent - // rejected Console input must not hide the callback cause. - // A preserved picture from another generation is unrelated. // 同 VM 保留最初故障原因,后续“域已停止”错误不能覆盖真正回调故障;其他世代旧图不沿用故障。 let fault = (document.frame.session == id) .then(|| document.frame.fault.clone()) @@ -810,6 +889,7 @@ impl Worker { for view in document.windows.values_mut() { view.frame.fault = Some(fault.clone()); } + // 尽力提交故障条,主响应仍保留原始执行错误;不能因二次故障覆盖真正原因。 let _ = current.commit(epoch, document); drop(current); notify(); @@ -817,6 +897,7 @@ impl Worker { compile_error(error) } Err(_) => { + // Rust panic 不作为普通语言错误继续执行,清运行时/命令资格但保留上次完整静态画面。 let mut document = current.document.clone(); document.frame.fault = Some("Runtime discarded after an internal execution failure".into()); @@ -826,6 +907,7 @@ impl Worker { // 输入 panic 保留最近画面但撤销运行时/命令并发布故障条,再将 self.repl 丢弃。 current.runtime_session = 0; current.window_commands.clear(); + // 优先保留原始 panic 的响应,故障画面提交尽力而为;不因二次提交失败重新使用已损坏 VM。 let _ = current.commit(epoch, document); drop(current); self.repl = None; @@ -838,29 +920,31 @@ impl Worker { } } - /// An accepted PUT changes the submission, but does not revoke the old - /// runtime. DELETE clears runtime_session and does revoke it. - /// 接受较新提交不等于立即销毁旧 VM;候选失败时仍需保留当前可用运行时。 + /// 新提交/候选过期或失败时调用,若旧 VM 仍拥有发布画面则只跟进 epoch;DELETE 已撤权威则丢弃它。 pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) { if let Some((epoch, runtime)) = &mut self.repl { if current.runtime_session == runtime.id { + // 旧程序还是真正交互域,接受次序更新但其实际对象和 session 身份都不改变。 *epoch = current.submission; } else { + // 权威已经撤销或切到另一代,不能仅因还持有 Rust ReplSession 就继续执行。 self.repl = None; } } } - /// 为完整 PUT 建立隔离候选,保留旧运行时直到新候选完整成功。 + /// begin_request 的 Code 分支调用,先核验接受身份,再分配新 session 创建隔离候选,旧 self.repl 保持不动。 fn begin_program_request( &mut self, job: &BuildRequest, namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 候选仅继承主壳当前逻辑尺寸,不继承旧程序的可变对象或副窗资源。 let viewport = match namespace.read() { Ok(current) if current.submission == job.submission => current.viewport, Ok(current) => { + // 排队期间已有更新 PUT/DELETE,当前候选还没执行就应拒绝,不浪费新 VM 构建。 self.preserve_live_owner(¤t); return RequestProgress::Complete(superseded(job.submission)); } @@ -880,6 +964,7 @@ impl Worker { )); } }; + // 分配过的 session 即使候选失败也不回收,避免排队旧输入命中下一次新域。 let id = self.next_session; // 候选装载运行在独立 ReplSession,未成功前不修改 self.repl,旧程序仍是权威展示来源。 let started = catch_unwind(AssertUnwindSafe(|| { @@ -891,6 +976,7 @@ impl Worker { viewport, ) })); + // 两层 Result 分别区分 panic 和语言/布局错误;None 文档表示保留完整候选继续视觉阶段。 let result = match started { // 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。 Ok(Ok((runtime, None))) => { @@ -906,7 +992,7 @@ impl Worker { RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) } - /// 推进候选的布局与视口收敛,检查其是否已被新提交撤销。 + /// resume_request 的 Candidate 分支调用,用同一个隔离 VM 继续布局/绘制,期间持续检查提交是否已过期。 fn resume_candidate( &mut self, job: &BuildRequest, @@ -914,10 +1000,9 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 接受身份验证和主壳尺寸采样同锁,旧副窗口尺寸不作为新域的布局输入。 let viewports = match namespace.read() { Ok(current) if current.submission == job.submission => { - // Secondary native resources still belong to the old VM. Only - // the primary shell's current viewport applies to a candidate. // 候选不沿用旧副窗口的视口;副窗口资源归旧 VM,只有主壳当前尺寸可作为新候选分配条件。 current .viewport @@ -936,8 +1021,11 @@ impl Worker { )); } }; + // 保留候选实际 session,不能在每片恢复时重新分配 ID 或重建 UI 类型域。 let id = runtime.id; + // 续跑视觉状态,不重执行顶层语句或 main;错误/panic 都交 finish_candidate 保留旧活程序。 let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + // 与活域一样,resize 可重定向外部目标或继续原 Work,但始终留在此候选 owning VM 上。 let document = runtime.resize_windows_incremental(&viewports, false)?; if runtime.has_pending_refresh() { return Ok(None); @@ -947,6 +1035,7 @@ impl Worker { document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; Ok(Some((document, runtime.take_window_commands()?))) })); + // Complete 才把持久 runtime、完整文档和命令作为一组交给切换发布,Pending 继续原 Box。 let result = match resumed { Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)), Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))), @@ -956,7 +1045,7 @@ impl Worker { RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) } - /// 候选成功才切换活跃 VM 和文档;失败保留先前已发布程序。 + /// 完整加载/恢复候选终结时调用,写锁内二次核验 submission,只有完整成功才切换文档、命令与活 VM。 fn finish_candidate( &mut self, job: &BuildRequest, @@ -965,6 +1054,7 @@ impl Worker { id: u64, result: CandidateOutcome, ) -> HttpResponse { + // 字节码/源视觉执行不持锁,只有最终这个切换边界持锁原子更新外部权威。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock poisoned"); }; @@ -990,8 +1080,6 @@ impl Worker { Err(error) => error_response(StatusCode(500), error.to_string()), }, failed => { - // The candidate never mutated the displayed program's heap. - // Restore that program's event epoch when a replacement fails. // 候选失败只更新旧活域 epoch,保留其堆与页面;不存在旧域时也不会借用其他会话伪造成功。 self.preserve_live_owner(¤t); drop(current); @@ -1007,7 +1095,7 @@ impl Worker { } } -/// 处理隔离表达式或一次性文档编译;编译前后核对 submission,只发布仍有效的完整快照。 +/// begin_request 的 Expression 分支和 API 测试一次性构建路径调用;无持久 VM 输出,源码构建前后核验接受身份。 fn build( job: &BuildRequest, namespace: &Arc>, @@ -1015,6 +1103,7 @@ fn build( ) -> Response>> { // 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。 if job.kind == RequestKind::Expression { + // 表达式只回文本,不能从已有画面反取 globals,成功也明确 persistent=false。 return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { Ok(Ok(value)) => json( StatusCode(200), @@ -1029,12 +1118,12 @@ fn build( Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), _ => {} } - // Only an independent native snapshot crosses into the published state. - // A fresh VM is discarded on success, error and unwind alike. // 一次性构建同样用独立 VM,计算锁外执行,返回后再次核对 submission 才能发布原生快照。 + // 包住一次性 VM 执行,panic 也不会让 HTTP 客户端一直等待失去响应的 worker。 let result = catch_unwind(AssertUnwindSafe(|| { endor_host::compile(&job.source, &job.namespace, job.submission) })); + // 重新取发布写锁再检查 submission,候选计算期间新请求可接受但旧结果无权覆盖。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; diff --git a/src/runtime/endor_host/manuscript_tests.rs b/src/runtime/endor_host/manuscript_tests.rs index 9b90ed2..478eefb 100644 --- a/src/runtime/endor_host/manuscript_tests.rs +++ b/src/runtime/endor_host/manuscript_tests.rs @@ -1,18 +1,27 @@ -//! Integration acceptance through the actual Endor VM and published frames. +//! Rust 测试框架通过 endor_host 的真实 VM/发布帧接口验收 illustrated-manuscript Endor 示例。 +//! 第一项检查指针驱动/火焰生命周期与基本像素,第二项检查 owner-scoped 动画任务、不同视口和绘制几何。 +//! write_png 仅在配置 TESSERA_MANUSCRIPT_SNAPSHOTS 时写图片;测试源码字符串与像素期望都是验收数据。 use super::*; use crate::document::RenderCommand; #[test] +/// 测试框架调用,证明动画由真实源状态和指针事件驱动,取消输入后源粒子会退场而非原生演示循环继续跑。 fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { + // 直接加载仓库随应用交付的示例,避免测试另写一份近似逻辑却无法覆盖实际程序。 let source = include_str!("../../../examples/illustrated-manuscript.endor"); + // 固定会话代际只用于事件身份,几何/Mount ID 从真正发布的文档取出。 let (mut session, mut document) = ReplSession::load(source, "app.main", 1, 735).unwrap(); + /// 本测试的同步求值助手,执行后只在有新文档时替换观察值,返回 Console 的字符串结果供断言。 fn evaluate(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String { + // execute 复用持久 globals,不根据浏览文档伪造对象状态。 let result = session.execute(source, "app.main").unwrap(); if let Some(next) = result.document { + // 无变化时保留先前完整帧,不能用默认空文档替换它而误判渲染退场。 *document = next; } result.value } + // 关闭自动计时器,后面显式 dragon.step 的次数才是唯一推进因素,消除定时采样干扰。 evaluate(&mut session, &mut document, "view.timer.cancel()"); document.validate().unwrap(); assert!( @@ -23,13 +32,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .any(|c| matches!(c, RenderCommand::Image { .. })) ); assert!(document.frame.mounts.iter().any(|m| m.pointer_events)); + // 读取模型的真实头段 x 作为基线,不靠图片相似度猜动画是否发生。 let before = evaluate(&mut session, &mut document, "dragon.segments.at(0).x"); + // 目标必须具有 pointer_events,发布几何提供局部区域,不硬编码内部出现编号。 let target = document .frame .mounts .iter() .find(|m| m.pointer_events) .unwrap(); + // 用发布版本与实际目标构造合法原始指针,坐标放在画面内部,避免命中边缘的额外裁剪条件。 let mut input = crate::events::Input { window: 1, session: 735, @@ -51,14 +63,17 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .document .unwrap_or(document); for _ in 0..8 { + // 只推进源函数,证明位置变化来自已收到的指针目标与程序状态,不是宿主隐藏动画线程。 document = session .execute("dragon.step()", "app.main") .unwrap() .document .unwrap_or(document); } + // 比较同一模型字段的前后值,检查显式推进阶段有实际位置变化,而非只生成更多绘图命令。 let after = evaluate(&mut session, &mut document, "dragon.segments.at(0).x"); assert_ne!(before, after); + // 事件每次使用最新实际几何版本,测试关注行为而非故意触发过期输入拒绝。 input.layout_version = document.frame.layout_version; input.kind = "pointerdown".into(); document = session @@ -67,6 +82,7 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .document .unwrap_or(document); for _ in 0..3 { + // pointerdown 激活喷火状态,少量显式帧之后应有源粒子实例进入集合。 document = session .execute("dragon.step()", "app.main") .unwrap() @@ -77,13 +93,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { evaluate(&mut session, &mut document, "dragon.flames.count"), "0" ); + // 在源模型侧检查粒子尺寸预算,避免仅像素图看起来小却实际对象无限长大。 let max_size = evaluate(&mut session, &mut document, "dragon.flames.at(0).size < 20"); assert_eq!(max_size, "true"); input.layout_version = document.frame.layout_version; input.kind = "pointercancel".into(); + // 取消必须沿同一输入分派路径清 pressed,不直接从测试修改 dragon.pressed 代替处理器。 session.dispatch(&input).unwrap(); assert_eq!(evaluate(&mut session, &mut document, "dragon.pressed"), "0"); for _ in 0..16 { + // 取消后继续更新已有粒子寿命,最终集合归零,验证没有仍被按住状态持续补发新火焰。 document = session .execute("dragon.step()", "app.main") .unwrap() @@ -94,32 +113,44 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { evaluate(&mut session, &mut document, "dragon.flames.count"), "0" ); + // 真正发布列表交软件渲染器,不从源模型的“将要绘制”对象构造另一份测试专用画面。 let list = document.display_list(1.).unwrap(); + // 预分配固定尺寸 RGB 像素,下面验证页面奶油背景确实进入最终合成结果。 let mut pixels = vec![0; 1024 * 800]; crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, 1024, 800).unwrap(); assert!(pixels.contains(&0xf4eee0)); } +/// 第二项集成测试共用的持久执行助手;保留最后完整文档,返回源值的 Console 表示,不重建 VM。 fn execute(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String { + // 执行源代码及其同步刷新,Option 文档为空只代表没有新发布,不代表旧画面失效。 let result = session.execute(source, "app.main").unwrap(); if let Some(next) = result.document { + // 有新完整帧才覆盖测试观察值,后续输入版本与快照都从此值读取。 *document = next; } result.value } +/// 第二项测试各阶段调用,先验证实际像素;仅明确配置快照目录时把同一像素编码为 PNG,便于人工检查。 fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) { + // scale 是物理像素倍率,显示列表和输出尺寸必须配套,不改变源程序使用的逻辑 viewport。 let list = document.display_list(scale).unwrap(); + // 软件渲染覆盖真实像素数组,断言并非只检查命令里有一条背景矩形。 let mut pixels = vec![0; w as usize * h as usize]; crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, w, h).unwrap(); assert!(pixels.iter().filter(|&&p| p == 0xf4eee0).count() > 1000); if let Ok(dir) = std::env::var("TESSERA_MANUSCRIPT_SNAPSHOTS") { + // 未设置目录时不产生文件;有目录才创建编码器,快照输出是显式选择的测试附加产物。 std::fs::create_dir_all(&dir).unwrap(); + // name 区分宽窄视口及静止/穿行/喷火阶段,图片来自当前实际发布帧而非 ImageGen。 let file = std::fs::File::create(std::path::Path::new(&dir).join(format!("endor-{name}.png"))) .unwrap(); + // 像素是 0xRRGGBB,编码为每通道 8 位 RGB,不能把 u32 内存字节直接当 RGBA 保存。 let mut encoder = png::Encoder::new(file, w, h); encoder.set_color(png::ColorType::Rgb); encoder.set_depth(png::BitDepth::Eight); + // 显式提取高到低三个颜色分量,避免平台端序影响导出的颜色。 let rgb: Vec<_> = pixels .iter() .flat_map(|p| [(p >> 16) as u8, (p >> 8) as u8, *p as u8]) @@ -133,12 +164,15 @@ fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) } #[test] +/// 测试框架调用,验证真实 owner 定时任务、宽窄/高倍视口、喷火上限和 Page 关闭后的动画取消。 fn manuscript_script_snapshots_and_owned_animation_lifecycle() { + // 只追加测试推进函数,原示例本体不改;每次 step 前刷新 lastMove 以稳定触发运动场景。 let source = format!( "{}\nfunction advanceTest():Unit {{dragon.lastMove=math.now() dragon.step()}}", include_str!("../../../examples/illustrated-manuscript.endor") ); for (width, height, scale) in [(1000., 900., 1.), (420., 760., 2.)] { + // 两种窗口使用独立 VM 与真实逻辑 viewport,窄视图同时覆盖高倍字体/图片采样。 let (mut session, mut document) = ReplSession::load_with_viewport( &source, "app.main", @@ -147,15 +181,19 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { Some(crate::layout::Size { width, height }), ) .unwrap(); - // The actual owner-scoped Task calls script tick, not a native demo loop. + // 真实 owner-scoped Task 调用源 tick,先比较时间字段确认计时器而非宿主演示循环在推进。 let before = execute(&mut session, &mut document, "dragon.time"); + // 等待超过示例定时截止时间,再在所属 VM poll,后台线程不会直接替程序改 time。 std::thread::sleep(std::time::Duration::from_millis(85)); if let Some(next) = session.poll_tasks().unwrap() { + // 只接收调度后真正完整发布文档,不从任务内部未收敛草稿截屏。 document = next; } + // time 变化证明 owner/key 当前任务获得了一次有效交付。 let after = execute(&mut session, &mut document, "dragon.time"); assert_ne!(before, after); execute(&mut session, &mut document, "view.timer.cancel()"); + // 自动动画确认后取消,后续快照阶段由固定次数 step 控制;像素尺寸按逻辑尺寸乘 scale。 let (w, h) = ((width * scale) as u32, (height * scale) as u32); write_png( &document, @@ -164,6 +202,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { scale, if width > 500. { "rest" } else { "narrow-rest" }, ); + // 找当前真实指针出现并克隆几何,下面分派会改 document,不能保留对旧文档的借用。 let mount = document .frame .mounts @@ -171,7 +210,9 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .find(|m| m.pointer_events) .unwrap() .clone(); + // 用视口比例选锚点,宽窄布局都位于同类内容区域,不依赖固定像素偏移。 let point = [mount.x + mount.width * 0.58, mount.y + mount.height * 0.50]; + // 会话/版本/目标均从本轮 VM 和已发布帧取得,确保命中的是此视口而不是另一测试残留。 let mut input = crate::events::Input { window: 1, session: 736, @@ -190,6 +231,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .document .unwrap_or(document); for _ in 0..12 { + // 固定推进源运动,生成穿行状态,快照仍走实际 scene/paint/软件渲染链。 execute(&mut session, &mut document, "advanceTest()"); } write_png( @@ -206,10 +248,11 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .unwrap() .document .unwrap_or(document); - // Hold long enough to reach the steady-state particle cap and exercise - // the worst paint workload, then release/cancel through the event path. + // 按住并推进足够帧数达到粒子稳态上限,覆盖比静止更重的真实 paint 工作量。 + // 计时仅作诊断输出,测试没有依赖机器速度的性能通过阈值。 let start = std::time::Instant::now(); for _ in 0..16 { + // 每次仍执行源 advanceTest,不能用原生测试循环直接生成粒子绕过语言能力。 execute(&mut session, &mut document, "advanceTest()"); } eprintln!( @@ -220,12 +263,14 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { execute(&mut session, &mut document, "dragon.flames.count <= 12"), "true" ); + // 从源 artwork 与粒子速度对比最终图片角度,验证精灵轴校正符合源模型方向。 execute( &mut session, &mut document, "function verifyFire(): Bool { art=dragon.artwork(0,0,1) c=art.at(art.count-1) p=dragon.flames.at(dragon.flames.count-1) return math.abs(c.angle+p.art.axis-math.atan2(p.vy,p.vx))<0.0001 }", ); assert_eq!(execute(&mut session, &mut document, "verifyFire()"), "true"); + // 单独验证最终绘图宽高,不只看粒子模型 size 字段,防止图片命令放大绕过尺寸预算。 execute( &mut session, &mut document, @@ -242,7 +287,9 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { scale, if width > 500. { "fire" } else { "narrow-fire" }, ); + // Page 关闭须立即取消其 Mount 所有的任务;后续即使保留 dragon 对象也不能继续定时更新。 execute(&mut session, &mut document, "page.close()"); + // 关闭时真实 time 是对照值,等待超过旧定时点再 poll 应完全不变。 let closed = execute(&mut session, &mut document, "dragon.time"); std::thread::sleep(std::time::Duration::from_millis(85)); session.poll_tasks().unwrap();