1344 lines
64 KiB
Rust
1344 lines
64 KiB
Rust
//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
|
||
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
|
||
//!
|
||
//! Rust 测试框架调用下方 #[test];大多数测试通过 TestServer::request 访问真实 tiny_http 路由和串行 VM worker。
|
||
//! 少量测试直接安装 Worker 的活会话以精确触发发布身份边界,仍调用真实 ReplSession 而非模拟对象堆。
|
||
//! r# 字符串中的 Endor 是测试夹具,行号/诊断也可能是契约的一部分;注释只加在 Rust 测试逻辑外层。
|
||
use super::*;
|
||
use std::{
|
||
io::Write,
|
||
net::{SocketAddr, TcpStream},
|
||
sync::atomic::{AtomicUsize, Ordering},
|
||
time::Duration,
|
||
};
|
||
|
||
#[test]
|
||
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
|
||
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
|
||
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
||
// 旧运行时真实创建多个窗口,随后只保留其纯数据画面以模拟 Console reset 后的静态投影。
|
||
let (_, mut picture) = endor_host::ReplSession::load(
|
||
include_str!("../../../examples/windows.endor"),
|
||
"app.main",
|
||
1,
|
||
150,
|
||
)
|
||
.unwrap();
|
||
// 把旧文档标成无活动 VM 的保留投影;编号相同也不能据此识别当前资源。
|
||
picture.frame.session = 0;
|
||
for view in picture.windows.values_mut() {
|
||
view.frame.session = 0;
|
||
}
|
||
// 新 VM 使用不同 session,但窗口分配器同样从 2 开始,故意形成“同号但不同代”的资源。
|
||
let mut fresh = endor_host::ReplSession::new(151).unwrap();
|
||
assert!(
|
||
fresh
|
||
.execute("pending=Window()\nui.window().resize(0,600)", "app.main")
|
||
.is_err()
|
||
);
|
||
assert!(fresh.accepts_window(2));
|
||
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||
document: picture,
|
||
submission: 1,
|
||
runtime_session: 151,
|
||
..NamespaceState::default()
|
||
}));
|
||
// 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。
|
||
let mut worker = Worker {
|
||
repl: Some((1, fresh)),
|
||
next_session: 151,
|
||
};
|
||
// 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。
|
||
let notify: Notify = Arc::new(|| {});
|
||
namespace.write().unwrap().window_viewports.insert(
|
||
2,
|
||
crate::layout::Size {
|
||
width: 800.0,
|
||
height: 600.0,
|
||
},
|
||
);
|
||
worker.refresh_viewport(&namespace, ¬ify);
|
||
assert_eq!(
|
||
namespace.read().unwrap().document.windows[&2].frame.session,
|
||
0
|
||
);
|
||
// 关闭信封指向 session=0 的旧投影,不能因新 VM 也有 window=2 就退役它。
|
||
let response = worker.close_inactive_window(
|
||
&crate::events::Input {
|
||
window: 2,
|
||
session: 0,
|
||
layout_version: 0,
|
||
mount: 0,
|
||
kind: "window-close".into(),
|
||
point: None,
|
||
delta: None,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
},
|
||
1,
|
||
&namespace,
|
||
¬ify,
|
||
);
|
||
assert_eq!(response.status_code(), StatusCode(200));
|
||
assert!(namespace.read().unwrap().document.windows.is_empty());
|
||
// 从 worker 真实活域验证新窗口仍存在,不仅检查画面副本是否被删。
|
||
let runtime = &mut worker.repl.as_mut().unwrap().1;
|
||
assert_eq!(runtime.id, 151);
|
||
assert!(runtime.accepts_window(2));
|
||
assert_eq!(
|
||
runtime.execute("pending.id()", "app.main").unwrap().value,
|
||
"2"
|
||
);
|
||
}
|
||
|
||
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
|
||
struct TestServer {
|
||
// 拥有实际 Server 以便 Drop 调用 unblock,中断 incoming_requests 等待。
|
||
server: Arc<Server>,
|
||
// 每例实际绑定端口,从 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 = ¤t.document.frame;
|
||
crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: frame.tabs[0].id,
|
||
kind: "tab-drop".into(),
|
||
point: None,
|
||
press_point: None,
|
||
delta: None,
|
||
screen_position: None,
|
||
tab_drop: Some(crate::events::TabDrop {
|
||
window: 1,
|
||
layout_version: frame.layout_version,
|
||
index: 2,
|
||
}),
|
||
}
|
||
};
|
||
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
|
||
let body = serde_json::to_string(&input).unwrap();
|
||
// 第一遍走真实事件路由与源覆盖,应只重排一次,不因 worker 分片重试业务方法。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
// 再经持久 Console 读同一共享状态,避免仅凭标签排列看不出重复回调。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value");
|
||
// value 是 Console JSON 字段中的显示字符串,不是测试自己复制的 Endor 数值。
|
||
let value: serde_json::Value = serde_json::from_str(&response.2).unwrap();
|
||
assert_eq!(value["value"], "1");
|
||
assert_eq!(
|
||
namespace.read().unwrap().document.frame.tabs[1].title,
|
||
"first"
|
||
);
|
||
assert_eq!(
|
||
client
|
||
.request("POST", "/v1/namespaces/app.main/events", &body)
|
||
.0,
|
||
409
|
||
);
|
||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
|
||
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
|
||
fn background_failure_preserves_its_cause_after_rejected_console_input() {
|
||
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
|
||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
|
||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
|
||
let client = TestServer {
|
||
address: server.server_addr().to_ip().unwrap(),
|
||
server,
|
||
api: Some(api),
|
||
};
|
||
// 零延时任务的源方法触发非法窗口尺寸,产生真正回调故障而非测试直接写 fault 字段。
|
||
let source = r#"class Failure {
|
||
function stop(self: Failure): Unit { ui.window().resize(0,600) }
|
||
}
|
||
failure=Failure()
|
||
page=Page()
|
||
page.render(TextBlock("preserved"))
|
||
task=ui.after(ui,"failure",0,failure.stop)"#;
|
||
// 源码候选本身先成功;随后 worker 空闲任务阶段才会触发 stop 中的 resize 错误。
|
||
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||
assert_eq!(result.0, 200, "{}", result.2);
|
||
// 重复 Console 输入会被停用域拒绝,但不能覆盖首次回调真正的 window size/stop 诊断。
|
||
let repl = "/v1/namespaces/app.main/repl";
|
||
assert_eq!(client.request("POST", repl, "42").0, 422);
|
||
// 保留第一次故障纯文本,对比后续拒绝输入后仍为同一个原因。
|
||
let fault = namespace
|
||
.read()
|
||
.unwrap()
|
||
.document
|
||
.frame
|
||
.fault
|
||
.clone()
|
||
.unwrap();
|
||
assert!(fault.contains("window size"), "{fault}");
|
||
assert!(fault.contains("stop"), "{fault}");
|
||
assert_eq!(client.request("POST", repl, "42").0, 422);
|
||
assert_eq!(
|
||
namespace.read().unwrap().document.frame.fault.as_ref(),
|
||
Some(&fault)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
|
||
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
|
||
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
|
||
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
|
||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
|
||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
|
||
let client = TestServer {
|
||
address: server.server_addr().to_ip().unwrap(),
|
||
server,
|
||
api: Some(api),
|
||
};
|
||
// 同一 Console 路由跨多个请求复用真实 globals,/code 读的是可见源快照而非内部变量表。
|
||
let repl = "/v1/namespaces/app.main/repl";
|
||
// 用于确认 reset 后旧画面/源码仍保留,完整 PUT 才切换到新程序。
|
||
let code = "/v1/namespaces/app.main/code";
|
||
// 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。
|
||
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
|
||
assert_eq!(created.0, 200, "{}", created.2);
|
||
// 将 HTTP 三元组中的正文解析为 API JSON,rendered/revision 与 value 各有独立断言。
|
||
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
|
||
assert_eq!(created["rendered"], false);
|
||
assert_eq!(created["revision"], 0);
|
||
// 第二次请求引用前一请求的对象,证明会话保留真实对象而非每次独立表达式求值。
|
||
let read: serde_json::Value =
|
||
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
|
||
assert_eq!(read["value"], created["value"]);
|
||
assert_eq!(
|
||
client.request("POST", repl, "ui.page().render(title)").0,
|
||
200
|
||
);
|
||
// render 后 GET 应识别为 transcript;reset 只清 VM 绑定,不应抹去此已发布正文。
|
||
let snapshot = client.request("GET", code, "");
|
||
assert!(
|
||
snapshot
|
||
.1
|
||
.to_lowercase()
|
||
.contains("x-tessera-source-kind: repl-transcript")
|
||
);
|
||
assert_eq!(namespace.read().unwrap().revision, 1);
|
||
assert_eq!(client.request("DELETE", repl, "").0, 200);
|
||
assert_eq!(client.request("GET", code, "").2, snapshot.2);
|
||
assert_eq!(client.request("POST", repl, "title").0, 422);
|
||
assert_eq!(namespace.read().unwrap().revision, 1);
|
||
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
|
||
assert_eq!(
|
||
client
|
||
.request("PUT", code, include_str!("../../../examples/page.endor"))
|
||
.0,
|
||
200
|
||
);
|
||
assert_eq!(client.request("POST", repl, "retained").0, 422);
|
||
assert_eq!(namespace.read().unwrap().revision, 2);
|
||
}
|
||
|
||
#[test]
|
||
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
|
||
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
|
||
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
|
||
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
|
||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||
// 原子计数观察真实文档发布通知,不把 HTTP Network 观察更新混入这个断言。
|
||
let notifications = Arc::new(AtomicUsize::new(0));
|
||
// move 闭包持克隆 Arc,测试仍可通过原 Arc 在请求完成后读累计次数。
|
||
let counter = notifications.clone();
|
||
// GUI 被计数器替代,实际 worker/发布路径保持不变;只有有效发布才应递增。
|
||
let notify: Notify = Arc::new(move || {
|
||
counter.fetch_add(1, Ordering::SeqCst);
|
||
});
|
||
// 装入计数通知,下面 PUT/DELETE 的总 revision 与通知数必须保持一致。
|
||
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
|
||
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
|
||
let client = TestServer {
|
||
address: server.server_addr().to_ip().unwrap(),
|
||
server,
|
||
api: Some(api),
|
||
};
|
||
// 一个固定合法 namespace 用于全部 CRUD,最后另测错误 namespace 不应创建新域。
|
||
let route = "/v1/namespaces/app.main/code";
|
||
// 使用实际交付示例,成功 PUT 的 GET 原文应与它逐字相同。
|
||
let example = include_str!("../../../examples/page.endor");
|
||
// 指南 GET 只能观察状态,不能产生文档 revision 或运行示例。
|
||
let (status, _, guide) = client.request("GET", "/agent", "");
|
||
assert_eq!(status, 200);
|
||
assert!(guide.contains("let myui = ui.page()"));
|
||
// 首次成功 PUT 产生 revision 1,响应 JSON 的版本应对应共享状态。
|
||
let (status, _, body) = client.request("PUT", route, example);
|
||
assert_eq!(status, 200, "{body}");
|
||
assert_eq!(
|
||
serde_json::from_str::<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, ¬ify);
|
||
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
|
||
assert_eq!(
|
||
worker
|
||
.repl
|
||
.as_mut()
|
||
.unwrap()
|
||
.1
|
||
.execute("retained.value", "app.main")
|
||
.unwrap()
|
||
.value,
|
||
"9"
|
||
);
|
||
{
|
||
// 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。
|
||
let mut current = namespace.write().unwrap();
|
||
current.submission = 3;
|
||
worker.preserve_live_owner(¤t);
|
||
}
|
||
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
|
||
namespace.write().unwrap().clear().unwrap();
|
||
worker.refresh_viewport(&namespace, ¬ify);
|
||
assert!(worker.repl.is_none());
|
||
}
|
||
|
||
#[test]
|
||
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
|
||
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
|
||
fn http_delete_remains_available_and_invalidates_queued_put() {
|
||
use std::{
|
||
sync::{Mutex, mpsc},
|
||
time::Instant,
|
||
};
|
||
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
|
||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||
// 第一通道通知主测试“worker 已进入首次发布通知”,随后才可靠地安排第二 PUT。
|
||
let (entered_tx, entered_rx) = mpsc::channel();
|
||
// 第二通道让作用域守卫无条件解除 worker 阻塞,避免测试 panic 后线程 join 永远挂起。
|
||
let (release_tx, release_rx) = mpsc::channel();
|
||
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
|
||
let release_rx = Mutex::new(release_rx);
|
||
// 只阻塞第一个通知,DELETE 自身的新通知必须仍能正常返回,不依赖固定 sleep 猜时序。
|
||
let calls = AtomicUsize::new(0);
|
||
// 此处故意制造受控阻塞;生产通知只投递 GUI 事件,没有这个测试通道等待。
|
||
let notify: Notify = Arc::new(move || {
|
||
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||
// 信号先通知测试再等待释放,保证下面读 GET/DELETE 时 worker 确在有界暂停点。
|
||
entered_tx.send(()).unwrap();
|
||
release_rx.lock().unwrap().recv().unwrap();
|
||
}
|
||
});
|
||
// 用真实 HTTP 接收线程证明 VM worker 被阻塞时,GET/DELETE 仍不会被同一队列堵住。
|
||
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
|
||
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
|
||
let client = TestServer {
|
||
address: server.server_addr().to_ip().unwrap(),
|
||
server,
|
||
api: Some(api),
|
||
};
|
||
// 析构守卫在 scoped 线程 join 前释放阻塞通知;即使断言 panic 也不能让 worker 永久等通道。
|
||
// 持有释放信号的发送端;离开作用域即通知 worker 继续,包括异常展开路径。
|
||
struct Release(mpsc::Sender<()>);
|
||
impl Drop for Release {
|
||
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
|
||
fn drop(&mut self) {
|
||
// 最佳努力释放:接收端已停止时发送失败无需让析构再 panic。
|
||
let _ = self.0.send(());
|
||
}
|
||
}
|
||
thread::scope(|scope| {
|
||
// guard 在作用域返回或断言展开时都释放,随后 scoped 线程才能可靠 join。
|
||
let release = Release(release_tx);
|
||
// 两个请求线程只借同一客户端地址/服务生命周期,request 每次自己建立 TCP 连接。
|
||
let client = &client;
|
||
// 全部操作指同一资源,DELETE 的 epoch 变化必须使排队第二个 PUT 失效。
|
||
let route = "/v1/namespaces/app.main/code";
|
||
// 首尾都用同一有效源码,第二 PUT 的 409 不能归因于源码编译失败。
|
||
let example = include_str!("../../../examples/page.endor");
|
||
// 首次发布将触发通知阻塞;在接到 entered 之前不推断它已经执行到提交点。
|
||
let first = scope.spawn(move || client.request("PUT", route, example));
|
||
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||
// 第二 PUT 由 HTTP 接收线程接受后在 VM 队列等待,source 与第一条完全相同。
|
||
let pending = scope.spawn(move || client.request("PUT", route, example));
|
||
// 明确截止时间限制接受序号观察,不把后台线程未调度当作已经过期或成功。
|
||
let deadline = Instant::now() + Duration::from_secs(10);
|
||
while namespace.read().unwrap().submission < 2 {
|
||
// 轮询真实 accepted submission,确认第二请求已入队后才发 DELETE,消除盲目等待竞态。
|
||
assert!(Instant::now() < deadline, "second PUT was not admitted");
|
||
thread::sleep(Duration::from_millis(5));
|
||
}
|
||
assert_eq!(client.request("GET", route, "").2, example);
|
||
assert_eq!(client.request("DELETE", route, "").0, 200);
|
||
// DELETE 已同步清空文档并提升提交身份,现在解除第一请求通知阻塞,让排队候选观察到失效。
|
||
drop(release);
|
||
assert_eq!(first.join().unwrap().0, 200);
|
||
assert_eq!(pending.join().unwrap().0, 409);
|
||
assert_eq!(client.request("GET", route, "").2, "");
|
||
assert_eq!(namespace.read().unwrap().revision, 2);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
/// 检查 code 路径解析只接受完整的单段 namespace;缺少后缀和嵌套斜线均返回 None。
|
||
fn extracts_namespace_only_from_code_routes() {
|
||
assert_eq!(
|
||
namespace_from_path("/v1/namespaces/app.main/code"),
|
||
Some("app.main")
|
||
);
|
||
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
|
||
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
|
||
}
|
||
|
||
#[test]
|
||
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
|
||
fn guide_contains_only_code_crud_routes() {
|
||
// 只生成指南文本,不启动服务或执行内嵌示例;检查静态对外接口的关键用语。
|
||
let guide = agent_guide(&NamespaceState::default());
|
||
assert!(guide.contains("Code CRUD"));
|
||
assert!(guide.contains("let myui = ui.page()"));
|
||
assert!(!guide.contains("export scene"));
|
||
assert!(guide.contains("PUT http://127.0.0.1:4737"));
|
||
assert!(!guide.contains("display_list"));
|
||
}
|
||
|
||
#[test]
|
||
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
|
||
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
|
||
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
|
||
// ready 定时器会把共享 count 从 0 递增,前台模式不应提前消费它,因此提供明确可观察顺序。
|
||
let source = r#"
|
||
class Action {
|
||
count: Object
|
||
function fire(self: Action): Unit { self.count.increment() }
|
||
}
|
||
count=ui.state(0)
|
||
action=Action(count)
|
||
ui.after(ui,"ready",0,action.fire)
|
||
page=Page()
|
||
page.render(TextBlock("input priority"))
|
||
"#;
|
||
// 同步加载初始画面但不轮询就绪任务,下面才能单独比较前台/空闲两种 worker 行为。
|
||
let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap();
|
||
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||
document,
|
||
submission: 1,
|
||
runtime_session: 801,
|
||
..NamespaceState::default()
|
||
}));
|
||
// 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。
|
||
let mut worker = Worker {
|
||
repl: Some((1, runtime)),
|
||
next_session: 801,
|
||
};
|
||
// 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。
|
||
let notify: Notify = Arc::new(|| {});
|
||
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
|
||
worker.refresh_viewport_mode(&namespace, ¬ify, true);
|
||
// 从同一真实 globals 读取 count,若前台边界抢跑 callback 这里会错误地变成 1。
|
||
let value = worker
|
||
.repl
|
||
.as_mut()
|
||
.unwrap()
|
||
.1
|
||
.execute("count.value", "app.main")
|
||
.unwrap()
|
||
.value;
|
||
assert_eq!(value, "0");
|
||
worker.refresh_viewport(&namespace, ¬ify);
|
||
// 空闲轮允许启动任务,若其刷新挂起,只继续视觉工作而不重复执行回调。
|
||
let runtime = &mut worker.repl.as_mut().unwrap().1;
|
||
for _ in 0..64 {
|
||
// 有界排空实际 PendingRefresh,退出条件来自运行时状态而非固定猜测需要几帧。
|
||
if !runtime.has_pending_refresh() {
|
||
break;
|
||
}
|
||
runtime
|
||
.resize_windows_incremental(&std::collections::BTreeMap::new(), false)
|
||
.unwrap();
|
||
}
|
||
// 完成空闲推进后 count 应恰为 1,既验证有交付,也排除挂起恢复重复调用。
|
||
let value = runtime.execute("count.value", "app.main").unwrap().value;
|
||
assert_eq!(value, "1");
|
||
}
|
||
|
||
#[test]
|
||
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
|
||
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
|
||
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
|
||
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
|
||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
|
||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||
viewport: Some(crate::layout::Size {
|
||
width: 600.0,
|
||
height: 400.0,
|
||
}),
|
||
..NamespaceState::default()
|
||
}));
|
||
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
|
||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
|
||
let client = TestServer {
|
||
address: server.server_addr().to_ip().unwrap(),
|
||
server,
|
||
api: Some(api),
|
||
};
|
||
// 按钮放在负世界坐标,主 Page 采用兼容 canvas 模式,事件必须经相机映射而不能只接非负外框位置。
|
||
let source = r#"
|
||
count=ui.state(0)
|
||
button=Button("world")
|
||
button.onClick(count.increment)
|
||
button.x=-160
|
||
button.y=-80
|
||
page=Page()
|
||
page.mode="canvas"
|
||
page.render(button)
|
||
"#;
|
||
// 加载真实画布,下面所有版本/会话由成功发布帧派生,不为测试手写假几何。
|
||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
// 每次生成时读取新版本;只有显式保存的 pan 原文用于随后重放过期请求。
|
||
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||
// Page 级相机要求 mount=0,不能把内容按钮的 Mount ID 当作整页导航目标。
|
||
let frame = &namespace.read().unwrap().document.frame;
|
||
serde_json::to_string(&crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: 0,
|
||
kind: kind.into(),
|
||
delta: Some(delta),
|
||
point,
|
||
press_point: None,
|
||
screen_position: None,
|
||
tab_drop: None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
// 真正 HTTP /events 不享受原生版本追随,因此旧正文重发应该是 409。
|
||
let event_path = "/v1/namespaces/app.main/events";
|
||
// 负 pan 把负世界位置移入视野,增量不能被当作非负窗口尺寸拒绝。
|
||
let pan = camera("page-pan", [-240.0, -160.0], None);
|
||
// 第一次合法平移改变实际 Surface;再发同一正文则验证严格版本边界。
|
||
let response = client.request("POST", event_path, &pan);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
assert_eq!(
|
||
namespace.read().unwrap().document.frame.surface.pan,
|
||
crate::geometry::Point::new(-240.0, -160.0)
|
||
);
|
||
assert_eq!(client.request("POST", event_path, &pan).0, 409);
|
||
// 倍率 2 和非原点锚点共同决定新 pan,后面的精确期望检查保持锚点世界位置的公式。
|
||
let response = client.request(
|
||
"POST",
|
||
event_path,
|
||
&camera("page-zoom", [2.0, 0.0], Some([120.0, 90.0])),
|
||
);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
{
|
||
// 一次发布读锁内同时断言 zoom 和 pan,不能混读缩放前后不同版本。
|
||
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
|
||
let current = namespace.read().unwrap();
|
||
assert_eq!(current.document.frame.surface.zoom, 2.0);
|
||
assert_eq!(
|
||
current.document.frame.surface.pan,
|
||
crate::geometry::Point::new(-180.0, -115.0)
|
||
);
|
||
}
|
||
// click 使用实际交互出现和世界负坐标,验证缩放后目标解析仍保留负坐标语义。
|
||
let input = {
|
||
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
|
||
let current = namespace.read().unwrap();
|
||
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
|
||
let frame = ¤t.document.frame;
|
||
// 找真正按钮出现而非 Page 根,确认布局几何本身保留负世界位置。
|
||
let mount = frame.mounts.iter().find(|m| m.interactive).unwrap();
|
||
assert!(mount.x < 0.0 && mount.y < 0.0);
|
||
crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: mount.id,
|
||
kind: "click".into(),
|
||
point: Some([-150.0, -70.0]),
|
||
press_point: Some([-150.0, -70.0]),
|
||
delta: None,
|
||
screen_position: None,
|
||
tab_drop: None,
|
||
}
|
||
};
|
||
// 成对按下/释放同点走 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 = ¤t.document.frame;
|
||
serde_json::to_string(&crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.canvas_viewport)
|
||
.unwrap()
|
||
.id,
|
||
kind: kind.into(),
|
||
delta: Some(delta),
|
||
point,
|
||
press_point: None,
|
||
screen_position: None,
|
||
tab_drop: None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
// 保留同一事件地址,平移/缩放行为只由输入 kind 和目标能力决定。
|
||
let path = "/v1/namespaces/app.main/events";
|
||
// 普通二维线性平移可无 point,仍必须有真实视口 Mount 和有限 delta。
|
||
let pan = camera("viewport-pan", [20.0, -10.0], None);
|
||
// 首次 pan 应成功,旧正文重发则过期;源模型相机状态后面经 REPL 验证。
|
||
let response = client.request("POST", path, &pan);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
assert_eq!(client.request("POST", path, &pan).0, 409);
|
||
// 局部缩放锚点先撤父偏移/边框再计算,不能直接用屏幕 80/60 作为内容相对坐标。
|
||
let response = client.request(
|
||
"POST",
|
||
path,
|
||
&camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])),
|
||
);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
for (expression, expected) in [
|
||
("view.camera.x", "45"),
|
||
("view.camera.y", "10"),
|
||
("view.camera.zoom", "2"),
|
||
] {
|
||
// 三个源属性分别比对,外观近似正确不能掩盖 x/y/zoom 中某个维度的变换错误。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
assert_eq!(
|
||
serde_json::from_str::<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 = ¤t.document.frame;
|
||
serde_json::to_string(&crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.scene3d_viewport)
|
||
.unwrap()
|
||
.id,
|
||
kind: kind.into(),
|
||
delta: Some(delta),
|
||
point,
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
// 同一事件路由支持不同源/原生控制器,不能为 PanOnly 建一条测试专用快捷调用。
|
||
let event_path = "/v1/namespaces/app.main/events";
|
||
// 视口内部锚点与倍率 2 应让默认透视相机距离减半,原正文重发仍必须 409。
|
||
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
|
||
// 保存第一次请求结果并打印正文诊断,便于区分参数错误和运行时调用失败。
|
||
let response = client.request("POST", event_path, &zoom);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
|
||
// 经同一持久 Console 查询/修改真实相机,不从 Frame 近似反解相机模型值。
|
||
let value = |expression: &str| {
|
||
// helper 仍跨 HTTP,确保候选保留的 globals 与事件真正执行域是同一份。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
serde_json::from_str::<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 = ¤t.document.frame;
|
||
serde_json::to_string(&crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.canvas_viewport && mount.panel.is_some())
|
||
.unwrap()
|
||
.id,
|
||
kind: kind.into(),
|
||
point,
|
||
press_point: None,
|
||
delta: Some(delta),
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
// 世界 UI 手势没有锚点不可可靠逆投影;第一条断言应 400 而不是执行近似屏幕平移。
|
||
let path = "/v1/namespaces/app.main/events";
|
||
assert_eq!(
|
||
client
|
||
.request("POST", path, &gesture("viewport-pan", [10., 0.], None))
|
||
.0,
|
||
400
|
||
);
|
||
// 给定屏幕中心锚点后,同一增量才有可计算的面板局部对应位移。
|
||
let response = client.request(
|
||
"POST",
|
||
path,
|
||
&gesture("viewport-pan", [10., 0.], Some([100., 100.])),
|
||
);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
// 查询真实模型数值,不只比视觉快照;返回 f32 供投影近似容差断言。
|
||
let value = |expression: &str| {
|
||
// 每次表达式仍走持久 Console,若被误路由到新 VM 会因 canvas/view 不存在失败。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
serde_json::from_str::<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 = ¤t.document.frame;
|
||
// 明确选择有原始指针能力的目标,绝对坐标为该出现原点加固定局部 35/27。
|
||
let mount = frame.mounts.iter().find(|m| m.pointer_events).unwrap();
|
||
crate::events::Input {
|
||
window: 1,
|
||
session: frame.session,
|
||
layout_version: frame.layout_version,
|
||
mount: mount.id,
|
||
kind: kind.into(),
|
||
point: Some([mount.x + 35., mount.y + 27.]),
|
||
press_point: None,
|
||
delta: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
}
|
||
};
|
||
// 保存最初正文作后续过期重放,不能重建最新版本后宣称在验证旧事件拒绝。
|
||
let body = serde_json::to_string(&input("pointermove")).unwrap();
|
||
// 第一遍合法移动应把局部坐标传给源 Tracker;它本身不一定触发视觉树变化。
|
||
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
|
||
assert_eq!(response.0, 200, "{}", response.2);
|
||
// Tracker 业务状态未被画面读取,不保证版本变化;显式改 target.width 产生真实新几何再重放旧正文。
|
||
assert_eq!(
|
||
client
|
||
.request("POST", "/v1/namespaces/app.main/repl", "target.width=201")
|
||
.0,
|
||
200
|
||
);
|
||
assert_eq!(
|
||
client
|
||
.request("POST", "/v1/namespaces/app.main/events", &body)
|
||
.0,
|
||
409
|
||
);
|
||
for (expr, expected) in [("tracker.x", "35"), ("tracker.y", "27")] {
|
||
// 逐轴验证源处理器收到的是 Mount 局部 35/27,而非 Column 偏移后的页面绝对坐标。
|
||
let r = client.request("POST", "/v1/namespaces/app.main/repl", expr);
|
||
assert_eq!(r.0, 200);
|
||
assert_eq!(
|
||
serde_json::from_str::<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"
|
||
);
|
||
}
|