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

1344 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, &notify);
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,
&notify,
);
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>,
// 每例实际绑定端口,从 server_addr 读取而非硬编码应用默认地址。
address: SocketAddr,
// Option 允许 Drop 取出句柄只 join 一次,不从借用的 self 中移动裸 JoinHandle。
api: Option<thread::JoinHandle<()>>,
}
impl TestServer {
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
// 不用直调 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 等待并等待线程结束,避免测试之间遗留监听线程。
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 = &current.document.frame;
crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame.tabs[0].id,
kind: "tab-drop".into(),
point: None,
press_point: None,
delta: None,
screen_position: None,
tab_drop: Some(crate::events::TabDrop {
window: 1,
layout_version: frame.layout_version,
index: 2,
}),
}
};
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
let body = serde_json::to_string(&input).unwrap();
// 第一遍走真实事件路由与源覆盖,应只重排一次,不因 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::<serde_json::Value>(&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}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&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, &notify);
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
assert_eq!(
worker
.repl
.as_mut()
.unwrap()
.1
.execute("retained.value", "app.main")
.unwrap()
.value,
"9"
);
{
// 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。
let mut current = namespace.write().unwrap();
current.submission = 3;
worker.preserve_live_owner(&current);
}
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
namespace.write().unwrap().clear().unwrap();
worker.refresh_viewport(&namespace, &notify);
assert!(worker.repl.is_none());
}
#[test]
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
fn http_delete_remains_available_and_invalidates_queued_put() {
use std::{
sync::{Mutex, mpsc},
time::Instant,
};
// 使用系统分配的回环端口,测试服务与应用固定 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 暴露。
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, &notify, 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, &notify);
// 空闲轮允许启动任务,若其刷新挂起,只继续视觉工作而不重复执行回调。
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 = &current.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,
}
};
// 成对按下/释放同点走 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::<serde_json::Value>(&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 = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport)
.unwrap()
.id,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
screen_position: None,
tab_drop: None,
})
.unwrap()
};
// 保留同一事件地址,平移/缩放行为只由输入 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::<serde_json::Value>(&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 = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport)
.unwrap()
.id,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
tab_drop: None,
screen_position: None,
})
.unwrap()
};
// 同一事件路由支持不同源/原生控制器,不能为 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::<serde_json::Value>(&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::<f32>().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 = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport && mount.panel.is_some())
.unwrap()
.id,
kind: kind.into(),
point,
press_point: None,
delta: Some(delta),
tab_drop: None,
screen_position: None,
})
.unwrap()
};
// 世界 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::<serde_json::Value>(&response.2).unwrap()["value"]
.as_str()
.unwrap()
.parse::<f32>()
.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 = &current.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::<serde_json::Value>(&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::<serde_json::Value>(&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 回执工作,等待有界确认;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()
.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::<serde_json::Value>(&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::<serde_json::Value>(&r.2).unwrap()["value"],
"0"
);
}